@segmentstream-cloud/app 0.6.0 → 0.8.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 +21 -21
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- 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 +25 -1
- package/dist/ontology-wire.js +151 -22
- package/dist/ontology.d.ts +3 -1
- package/dist/ontology.js +98 -17
- package/dist/permissions.js +5 -2
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +116 -0
- package/package.json +6 -2
package/README.md
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# @segmentstream-cloud/app
|
|
2
2
|
|
|
3
|
-
The ontology client uses
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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.
|
|
8
9
|
|
|
9
10
|
Sign-in, users, permissions, the ontology client and warehouse data access for
|
|
10
11
|
SegmentStream workspace apps built with Next.js (App Router, version 16 or later) and
|
|
@@ -117,7 +118,7 @@ authUserId?, organizationId, workspaceId }`. `id` is the person's ontology `user
|
|
|
117
118
|
or use another email.
|
|
118
119
|
- `getUser()` returns the same, or `null` when signed out; it throws
|
|
119
120
|
`SegmentStreamApiError` with `code: "not_invited"` for people without an invitation.
|
|
120
|
-
- `can("lead:
|
|
121
|
+
- `can("lead:changeStatus")` is `true` when the user's permissions cover the
|
|
121
122
|
permission. Roles may hold wildcards: `lead:*`, `*:read`, `*:*`. `isAllowed` and
|
|
122
123
|
`grants` apply the same rule to any list of permissions.
|
|
123
124
|
- `signOut()` ends the session, clears its cookies and redirects to `/sign-in` (or
|
|
@@ -132,22 +133,22 @@ user in Server Functions and Route Handlers too; do not rely on the proxy alone.
|
|
|
132
133
|
(no Tailwind). Every class starts with `ss-`; the `--ss-*` custom properties on
|
|
133
134
|
`:root` set colours, radius and font.
|
|
134
135
|
|
|
135
|
-
| Export
|
|
136
|
-
|
|
|
137
|
-
| `<SignIn />`
|
|
138
|
-
| `useSignIn()`
|
|
139
|
-
| `<SignedIn>`, `<SignedOut>`
|
|
140
|
-
| `<Can permission="lead:
|
|
141
|
-
| `<UserMenu />`
|
|
142
|
-
| `useUser()`
|
|
143
|
-
| `usePermissions()`
|
|
144
|
-
| `useSession()`, `signOut()`
|
|
136
|
+
| Export | Does |
|
|
137
|
+
| -------------------------------------- | ------------------------------------------------------------------------------- |
|
|
138
|
+
| `<SignIn />` | Email, then a six-digit code or a sign-in link; resend after 30 seconds, errors |
|
|
139
|
+
| `useSignIn()` | The same sign-in without markup, for the app's own form |
|
|
140
|
+
| `<SignedIn>`, `<SignedOut>` | Render children by session state |
|
|
141
|
+
| `<Can permission="lead:changeStatus">` | Renders children when the user holds the permission; `fallback` otherwise |
|
|
142
|
+
| `<UserMenu />` | Name, email, roles and sign-out |
|
|
143
|
+
| `useUser()` | `{ status, user, error }` from `/api/ontology/me`, loaded once per session |
|
|
144
|
+
| `usePermissions()` | `{ isLoading, roles, permissions, can(permission) }` |
|
|
145
|
+
| `useSession()`, `signOut()` | The Neon Auth session in the browser; sign-out from the browser |
|
|
145
146
|
|
|
146
147
|
`<Can>` only hides controls: the ontology service checks every read and action. A
|
|
147
148
|
Server Component may wrap server-rendered children in `<Can>`:
|
|
148
149
|
|
|
149
150
|
```tsx
|
|
150
|
-
<Can permission="lead:
|
|
151
|
+
<Can permission="lead:changeStatus" fallback={lead.status}>
|
|
151
152
|
<ChangeStatusForm lead={lead.id} />
|
|
152
153
|
</Can>
|
|
153
154
|
```
|
|
@@ -260,7 +261,7 @@ Records, action inputs and action results are typed from the ontology:
|
|
|
260
261
|
```ts
|
|
261
262
|
const { items, nextCursor } = await segmentstream.ontology.lead.find({
|
|
262
263
|
where: { status: { in: ["new", "contacted"] } },
|
|
263
|
-
orderBy: [{ field: "
|
|
264
|
+
orderBy: [{ field: "createdAt", direction: "desc" }],
|
|
264
265
|
include: { owner: true },
|
|
265
266
|
limit: 20,
|
|
266
267
|
});
|
|
@@ -269,11 +270,10 @@ const history = await segmentstream.ontology.lead.history(id, { limit: 50 });
|
|
|
269
270
|
const page = await segmentstream.ontology.lead.query({ where: { email: { startsWith: "ada" } } });
|
|
270
271
|
|
|
271
272
|
const input = { lead: id, status: "contacted" } as const;
|
|
272
|
-
const check = await segmentstream.ontology.lead.actions.
|
|
273
|
+
const check = await segmentstream.ontology.lead.actions.changeStatus(input, {
|
|
273
274
|
validateOnly: true,
|
|
274
275
|
});
|
|
275
|
-
const { result, validation, edits } =
|
|
276
|
-
await segmentstream.ontology.lead.actions.change_status(input);
|
|
276
|
+
const { result, validation, edits } = await segmentstream.ontology.lead.actions.changeStatus(input);
|
|
277
277
|
```
|
|
278
278
|
|
|
279
279
|
- `find` sends the query as URL parameters, `query` as a JSON body; both return
|
package/dist/index.d.ts
CHANGED
|
@@ -30,3 +30,4 @@ export type { SegmentStreamAppConfig } from "./config.js";
|
|
|
30
30
|
export type { SegmentStreamRouteHandlers, RouteHandler } from "./handlers.js";
|
|
31
31
|
export type { ProxyOptions, SegmentStreamProxy } from "./proxy.js";
|
|
32
32
|
export type { AppUser } from "./tokens.js";
|
|
33
|
+
export { createBlobStorageClient, type BlobUploadTicket, type FileRevision, type FileTransfer, type FileUploadTarget, type UploadSession, type FileUploadOptions, } from "./storage.js";
|
package/dist/index.js
CHANGED
|
@@ -19,3 +19,4 @@ export function createSegmentStreamApp(config) {
|
|
|
19
19
|
export { NOT_INVITED, SIGN_IN_LINK_INVALID, SegmentStreamApiError } from "./errors.js";
|
|
20
20
|
export { grants, isAllowed } from "./permissions.js";
|
|
21
21
|
export { sql } from "./parameters.js";
|
|
22
|
+
export { createBlobStorageClient, } from "./storage.js";
|
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
|
+
}
|
package/dist/ontology-wire.d.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import type { OntologyQuery, Where } from "./ontology.js";
|
|
1
|
+
import type { Edit, OntologyQuery, Where } from "./ontology.js";
|
|
2
2
|
interface CatalogProperty {
|
|
3
3
|
readonly name: string;
|
|
4
4
|
readonly type: string;
|
|
5
|
+
readonly options?: ReadonlyArray<{
|
|
6
|
+
readonly value: string;
|
|
7
|
+
}>;
|
|
5
8
|
}
|
|
6
9
|
interface CatalogRelationship {
|
|
7
10
|
readonly name: string;
|
|
@@ -13,6 +16,22 @@ interface CatalogEntity {
|
|
|
13
16
|
readonly name: string;
|
|
14
17
|
readonly properties: ReadonlyArray<CatalogProperty>;
|
|
15
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>;
|
|
16
35
|
}
|
|
17
36
|
export interface WireCatalog {
|
|
18
37
|
readonly hash?: string;
|
|
@@ -25,9 +44,14 @@ export declare function encodeWhere(input: {
|
|
|
25
44
|
readonly getCatalog: () => Promise<WireCatalog>;
|
|
26
45
|
}): Promise<Where | undefined>;
|
|
27
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;
|
|
28
50
|
export declare function responseFromWire(input: {
|
|
29
51
|
readonly method: "GET" | "POST";
|
|
30
52
|
readonly path: string;
|
|
31
53
|
readonly payload: unknown;
|
|
32
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>;
|
|
33
57
|
export {};
|
package/dist/ontology-wire.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { toCamelCase, toSnakeCase } from "./ontology-names.js";
|
|
1
2
|
const FILTER_OPERATORS = [
|
|
2
3
|
"eq",
|
|
3
4
|
"ne",
|
|
@@ -15,24 +16,10 @@ const SYSTEM_FIELDS = ["id", "created_at", "updated_at", "created_by", "updated_
|
|
|
15
16
|
function isRecord(value) {
|
|
16
17
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
18
|
}
|
|
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
19
|
/** Catalog metadata distinguishes relationship filters from opaque JSON property values. */
|
|
33
20
|
export async function encodeWhere(input) {
|
|
34
21
|
const { where, entity, getCatalog } = input;
|
|
35
|
-
if (where === undefined
|
|
22
|
+
if (where === undefined) {
|
|
36
23
|
return where;
|
|
37
24
|
}
|
|
38
25
|
const catalog = await getCatalog();
|
|
@@ -52,12 +39,13 @@ function whereFromCatalog(input) {
|
|
|
52
39
|
encodedWhere[field] = whereFromCatalog({ where: condition, entity, catalog });
|
|
53
40
|
continue;
|
|
54
41
|
}
|
|
55
|
-
const
|
|
42
|
+
const wireField = toSnakeCase(field);
|
|
43
|
+
const relationship = relationshipTarget({ field: wireField, entity, definition, catalog });
|
|
56
44
|
if (relationship !== undefined && isRecord(condition)) {
|
|
57
|
-
encodedWhere[
|
|
45
|
+
encodedWhere[wireField] = relationshipFilterToWire({ condition, relationship, catalog });
|
|
58
46
|
continue;
|
|
59
47
|
}
|
|
60
|
-
encodedWhere[
|
|
48
|
+
encodedWhere[wireField] = fieldFilterToWire({ field: wireField, condition, definition });
|
|
61
49
|
}
|
|
62
50
|
return encodedWhere;
|
|
63
51
|
}
|
|
@@ -95,10 +83,10 @@ function isGeneratedField(field, definition) {
|
|
|
95
83
|
}
|
|
96
84
|
function fieldFilterToWire(input) {
|
|
97
85
|
const { field, condition, definition } = input;
|
|
86
|
+
const property = definition?.properties.find((candidate) => candidate.name === field);
|
|
98
87
|
if (!isRecord(condition)) {
|
|
99
|
-
return condition;
|
|
88
|
+
return selectToWire(condition, property);
|
|
100
89
|
}
|
|
101
|
-
const property = definition?.properties.find((candidate) => candidate.name === field);
|
|
102
90
|
if (property === undefined && !isGeneratedField(field, definition)) {
|
|
103
91
|
return condition;
|
|
104
92
|
}
|
|
@@ -108,7 +96,7 @@ function fieldFilterToWire(input) {
|
|
|
108
96
|
}
|
|
109
97
|
const encodedFilter = Object.create(null);
|
|
110
98
|
for (const [operator, operand] of Object.entries(condition)) {
|
|
111
|
-
encodedFilter[operatorToWire(operator)] = operand;
|
|
99
|
+
encodedFilter[operatorToWire(operator)] = selectToWire(operand, property);
|
|
112
100
|
}
|
|
113
101
|
return encodedFilter;
|
|
114
102
|
}
|
|
@@ -125,10 +113,136 @@ export function queryToWire(query) {
|
|
|
125
113
|
const { orderBy, where, ...rest } = query;
|
|
126
114
|
return {
|
|
127
115
|
...rest,
|
|
128
|
-
...(orderBy === undefined
|
|
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
|
+
}),
|
|
129
124
|
...(where === undefined ? {} : { where }),
|
|
130
125
|
};
|
|
131
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
|
+
property?.type === "blob" && isRecord(child)
|
|
237
|
+
? Object.fromEntries(Object.entries(child).map(([key, value]) => [toSnakeCase(key), value]))
|
|
238
|
+
: (action === "link" || action === "unlink") &&
|
|
239
|
+
field === "relationship" &&
|
|
240
|
+
typeof child === "string"
|
|
241
|
+
? toSnakeCase(child)
|
|
242
|
+
: selectToWire(child, property),
|
|
243
|
+
];
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
132
246
|
function isPagedRoute(method, route) {
|
|
133
247
|
if (route.endsWith("/history")) {
|
|
134
248
|
return true;
|
|
@@ -159,3 +273,18 @@ export function responseFromWire(input) {
|
|
|
159
273
|
const items = page.items.map(historyEntryFromWire);
|
|
160
274
|
return { ...page, nextCursor, items };
|
|
161
275
|
}
|
|
276
|
+
/** Decode current edits using the current catalog; stored historical changes retain their names. */
|
|
277
|
+
export function editsFromWire(edits, catalog) {
|
|
278
|
+
return edits.map((edit) => {
|
|
279
|
+
const before = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.before])), edit.entity, catalog);
|
|
280
|
+
const after = recordFromWire(Object.fromEntries(Object.entries(edit.changes).map(([field, change]) => [field, change.after])), edit.entity, catalog);
|
|
281
|
+
return {
|
|
282
|
+
...edit,
|
|
283
|
+
entity: toCamelCase(edit.entity),
|
|
284
|
+
changes: Object.fromEntries(Object.keys(before).map((field) => [
|
|
285
|
+
field,
|
|
286
|
+
{ before: before[field], after: after[field] },
|
|
287
|
+
])),
|
|
288
|
+
};
|
|
289
|
+
});
|
|
290
|
+
}
|
package/dist/ontology.d.ts
CHANGED
|
@@ -121,6 +121,8 @@ export interface RequestOptions {
|
|
|
121
121
|
export interface ActionOptions extends RequestOptions {
|
|
122
122
|
/** Validate the input and report issues in `validation` without a write. */
|
|
123
123
|
readonly validateOnly?: boolean;
|
|
124
|
+
readonly idempotencyKey?: string;
|
|
125
|
+
readonly createIntent?: string;
|
|
124
126
|
}
|
|
125
127
|
export interface HistoryOptions extends RequestOptions {
|
|
126
128
|
readonly after?: string;
|
|
@@ -222,7 +224,7 @@ export interface OntologyTransport {
|
|
|
222
224
|
}
|
|
223
225
|
/**
|
|
224
226
|
* 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.
|
|
227
|
+
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
|
|
226
228
|
*/
|
|
227
229
|
export declare function createOntologyClient<O>(transport: OntologyTransport, aggregateTransport?: OntologyTransport): OntologyClient<O>;
|
|
228
230
|
/** `find` sends the query as URL parameters: `where` as JSON, `orderBy` as `field:direction`. */
|
package/dist/ontology.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { SegmentStreamApiError, apiErrorFrom, readJson } from "./errors.js";
|
|
2
|
-
import { encodeWhere, queryToWire, responseFromWire } from "./ontology-wire.js";
|
|
3
|
-
|
|
4
|
-
const
|
|
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;
|
|
5
6
|
/** Names a caller may probe on any object; never an entity or an action. */
|
|
6
7
|
const RESERVED = new Set([
|
|
7
8
|
"then",
|
|
@@ -18,7 +19,7 @@ const RESERVED = new Set([
|
|
|
18
19
|
]);
|
|
19
20
|
/**
|
|
20
21
|
* Entities and actions exist only in the app's types, so the client resolves them by name when
|
|
21
|
-
* they are used: `ontology.lead.actions.
|
|
22
|
+
* they are used: `ontology.lead.actions.changeStatus(…)` posts to `lead/actions/change_status`.
|
|
22
23
|
*/
|
|
23
24
|
export function createOntologyClient(transport, aggregateTransport) {
|
|
24
25
|
const entities = new Map();
|
|
@@ -31,12 +32,16 @@ export function createOntologyClient(transport, aggregateTransport) {
|
|
|
31
32
|
let client = entities.get(property);
|
|
32
33
|
if (client === undefined) {
|
|
33
34
|
client = {
|
|
34
|
-
...entityClient(property, catalogReader.transport, catalogReader.getCatalog),
|
|
35
|
+
...entityClient(toSnakeCase(property), catalogReader.transport, catalogReader.getCatalog),
|
|
35
36
|
aggregate: (request, options = {}) => {
|
|
36
37
|
if (!aggregateTransport) {
|
|
37
38
|
throw new TypeError("An aggregate transport is required.");
|
|
38
39
|
}
|
|
39
|
-
return send(aggregateTransport, "POST", `/${property}/aggregate`, request, options.signal)
|
|
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
|
+
}));
|
|
40
45
|
},
|
|
41
46
|
};
|
|
42
47
|
entities.set(property, client);
|
|
@@ -79,12 +84,45 @@ function createCatalogReader(transport) {
|
|
|
79
84
|
function entityClient(entity, transport, getCatalog) {
|
|
80
85
|
const base = `/${entity}`;
|
|
81
86
|
const actionCalls = new Map();
|
|
82
|
-
const runAction = (name, input, options = {}) =>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
:
|
|
87
|
-
|
|
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, options.idempotencyKey, options.createIntent].every((value) => value === undefined)
|
|
93
|
+
? {}
|
|
94
|
+
: {
|
|
95
|
+
options: {
|
|
96
|
+
...(options.validateOnly === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { validate_only: options.validateOnly }),
|
|
99
|
+
...(options.idempotencyKey === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { idempotency_key: options.idempotencyKey }),
|
|
102
|
+
...(options.createIntent === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { create_intent: options.createIntent }),
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
}, options.signal);
|
|
108
|
+
const response = { ...wireResponse, edits: editsFromWire(wireResponse.edits, catalog) };
|
|
109
|
+
if (action === "create" || action === "update") {
|
|
110
|
+
return { ...response, result: recordFromWire(response.result, entity, catalog) };
|
|
111
|
+
}
|
|
112
|
+
if (action.startsWith("upsert_by_") &&
|
|
113
|
+
response.result !== null &&
|
|
114
|
+
typeof response.result === "object" &&
|
|
115
|
+
!Array.isArray(response.result)) {
|
|
116
|
+
return {
|
|
117
|
+
...response,
|
|
118
|
+
result: {
|
|
119
|
+
...response.result,
|
|
120
|
+
record: recordFromWire(response.result.record, entity, catalog),
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return response;
|
|
125
|
+
};
|
|
88
126
|
const actions = new Proxy(Object.create(null), {
|
|
89
127
|
get(_target, property) {
|
|
90
128
|
if (typeof property !== "string" || RESERVED.has(property) || !ACTION_NAME.test(property)) {
|
|
@@ -100,15 +138,28 @@ function entityClient(entity, transport, getCatalog) {
|
|
|
100
138
|
});
|
|
101
139
|
return {
|
|
102
140
|
find: async (query = {}, options = {}) => {
|
|
141
|
+
const catalog = await getCatalog();
|
|
103
142
|
const where = await encodeWhere({ where: query.where, entity, getCatalog });
|
|
104
143
|
const search = findParameters({ ...query, where }).toString();
|
|
105
|
-
|
|
144
|
+
const page = await send(transport, "GET", search === "" ? base : `${base}?${search}`, undefined, options.signal);
|
|
145
|
+
return {
|
|
146
|
+
...page,
|
|
147
|
+
items: page.items.map((record) => recordFromWire(record, entity, catalog)),
|
|
148
|
+
};
|
|
106
149
|
},
|
|
107
150
|
query: async (query = {}, options = {}) => {
|
|
151
|
+
const catalog = await getCatalog();
|
|
108
152
|
const where = await encodeWhere({ where: query.where, entity, getCatalog });
|
|
109
|
-
|
|
153
|
+
const page = await send(transport, "POST", `${base}/query`, queryToWire({ ...query, where }), options.signal);
|
|
154
|
+
return {
|
|
155
|
+
...page,
|
|
156
|
+
items: page.items.map((record) => recordFromWire(record, entity, catalog)),
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
get: (id, options = {}) => {
|
|
160
|
+
const encodedId = recordId(id);
|
|
161
|
+
return getCatalog().then(async (catalog) => recordFromWire(await send(transport, "GET", `${base}/${encodedId}`, undefined, options.signal), entity, catalog));
|
|
110
162
|
},
|
|
111
|
-
get: (id, options = {}) => send(transport, "GET", `${base}/${recordId(id)}`, undefined, options.signal),
|
|
112
163
|
history: (id, options = {}) => {
|
|
113
164
|
const search = new URLSearchParams();
|
|
114
165
|
if (options.after !== undefined) {
|
|
@@ -143,12 +194,14 @@ export function findParameters(query) {
|
|
|
143
194
|
search.set("where", JSON.stringify(query.where));
|
|
144
195
|
}
|
|
145
196
|
if (query.orderBy !== undefined && query.orderBy.length > 0) {
|
|
146
|
-
search.set("order_by", query.orderBy
|
|
197
|
+
search.set("order_by", query.orderBy
|
|
198
|
+
.map(({ field, direction }) => `${toSnakeCase(field)}:${direction ?? "asc"}`)
|
|
199
|
+
.join(","));
|
|
147
200
|
}
|
|
148
201
|
if (query.include !== undefined) {
|
|
149
202
|
const relationships = Object.keys(query.include).filter((name) => query.include?.[name] === true);
|
|
150
203
|
if (relationships.length > 0) {
|
|
151
|
-
search.set("include", relationships.join(","));
|
|
204
|
+
search.set("include", relationships.map(toSnakeCase).join(","));
|
|
152
205
|
}
|
|
153
206
|
}
|
|
154
207
|
if (query.after !== undefined) {
|
|
@@ -159,6 +212,34 @@ export function findParameters(query) {
|
|
|
159
212
|
}
|
|
160
213
|
return search;
|
|
161
214
|
}
|
|
215
|
+
/** The platform aggregate API keeps its existing protocol keys; ontology identifiers are canonical. */
|
|
216
|
+
function aggregateToWire(request) {
|
|
217
|
+
return {
|
|
218
|
+
...request,
|
|
219
|
+
dimensions: request.dimensions.map(toSnakeCase),
|
|
220
|
+
measures: request.measures.map(toSnakeCase),
|
|
221
|
+
...(request.granularityOf === undefined
|
|
222
|
+
? {}
|
|
223
|
+
: { granularityOf: toSnakeCase(request.granularityOf) }),
|
|
224
|
+
...(request.filters === undefined
|
|
225
|
+
? {}
|
|
226
|
+
: {
|
|
227
|
+
filters: request.filters.map((filter) => ({
|
|
228
|
+
...filter,
|
|
229
|
+
dimension: toSnakeCase(filter.dimension),
|
|
230
|
+
})),
|
|
231
|
+
}),
|
|
232
|
+
...(request.orderBy === undefined
|
|
233
|
+
? {}
|
|
234
|
+
: {
|
|
235
|
+
orderBy: request.orderBy.map((order) => ({
|
|
236
|
+
...order,
|
|
237
|
+
...(order.dimension === undefined ? {} : { dimension: toSnakeCase(order.dimension) }),
|
|
238
|
+
...(order.measure === undefined ? {} : { measure: toSnakeCase(order.measure) }),
|
|
239
|
+
})),
|
|
240
|
+
}),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
162
243
|
async function send(transport, method, path, body, signal) {
|
|
163
244
|
const headers = new Headers({ accept: "application/json" });
|
|
164
245
|
if (body !== undefined) {
|
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
12
|
/** `<entity>:<verb>` with lowercase snake_case entities and lowercase snake_case verbs, or `*` for either. */
|
|
12
|
-
const PERMISSION = /^(\*|[a-z][a-
|
|
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) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { OntologyTransport } from "./ontology.js";
|
|
2
|
+
export interface BlobUploadTicket {
|
|
3
|
+
readonly uploadId: string;
|
|
4
|
+
}
|
|
5
|
+
export interface FileRevision {
|
|
6
|
+
readonly id: string;
|
|
7
|
+
readonly filename: string;
|
|
8
|
+
readonly contentType: string;
|
|
9
|
+
readonly size: number;
|
|
10
|
+
readonly createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileTransfer {
|
|
13
|
+
readonly url: string;
|
|
14
|
+
readonly method: "GET" | "PUT";
|
|
15
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
16
|
+
readonly expiresAt: string;
|
|
17
|
+
}
|
|
18
|
+
export interface FileUploadTarget {
|
|
19
|
+
readonly entity: string;
|
|
20
|
+
readonly property: string;
|
|
21
|
+
readonly action: string;
|
|
22
|
+
readonly recordId?: string;
|
|
23
|
+
readonly createIntent?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface UploadSession {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly state: string;
|
|
28
|
+
readonly expiresAt: string;
|
|
29
|
+
readonly ticket?: BlobUploadTicket;
|
|
30
|
+
}
|
|
31
|
+
export interface FileUploadOptions {
|
|
32
|
+
readonly signal?: AbortSignal;
|
|
33
|
+
/** Reuse this UUID when retrying an upload and its entity action. */
|
|
34
|
+
readonly idempotencyKey?: string;
|
|
35
|
+
/** Reports transfer start and completion; supply a transfer adapter for granular progress. */
|
|
36
|
+
readonly onProgress?: (transferred: number, total: number) => void;
|
|
37
|
+
}
|
|
38
|
+
/** Portable client: ontology requests use the member transport; bytes use only a scoped grant. */
|
|
39
|
+
export declare function createBlobStorageClient(transport: OntologyTransport, transfer?: typeof fetch): {
|
|
40
|
+
status: (id: string, signal?: AbortSignal) => Promise<UploadSession>;
|
|
41
|
+
cancel: (id: string) => Promise<UploadSession>;
|
|
42
|
+
uploadForAction(target: FileUploadTarget, file: File, options?: FileUploadOptions): Promise<{
|
|
43
|
+
ticket: BlobUploadTicket;
|
|
44
|
+
options: {
|
|
45
|
+
createIntent?: string | undefined;
|
|
46
|
+
idempotencyKey: string;
|
|
47
|
+
};
|
|
48
|
+
}>;
|
|
49
|
+
download(entity: string, recordId: string, property: string, options?: {
|
|
50
|
+
readonly revisionId?: string;
|
|
51
|
+
readonly inline?: boolean;
|
|
52
|
+
readonly signal?: AbortSignal;
|
|
53
|
+
}): Promise<FileTransfer>;
|
|
54
|
+
};
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { apiErrorFrom, readJson } from "./errors.js";
|
|
2
|
+
import { toSnakeCase } from "./ontology-names.js";
|
|
3
|
+
/** Portable client: ontology requests use the member transport; bytes use only a scoped grant. */
|
|
4
|
+
export function createBlobStorageClient(transport, transfer = globalThis.fetch) {
|
|
5
|
+
const send = async (path, method, body, signal) => {
|
|
6
|
+
const response = await transport.fetch(path, {
|
|
7
|
+
method,
|
|
8
|
+
...(body === undefined
|
|
9
|
+
? {}
|
|
10
|
+
: { headers: { "content-type": "application/json" }, body: JSON.stringify(body) }),
|
|
11
|
+
...(signal ? { signal } : {}),
|
|
12
|
+
});
|
|
13
|
+
const data = await readJson(response);
|
|
14
|
+
if (!response.ok) {
|
|
15
|
+
throw apiErrorFrom(response.status, data, "The ontology service rejected the file request.");
|
|
16
|
+
}
|
|
17
|
+
return data;
|
|
18
|
+
};
|
|
19
|
+
const sessionPath = (id) => `/uploads/${encodeURIComponent(id)}`;
|
|
20
|
+
const readSession = async (path, method, body, signal) => {
|
|
21
|
+
const { expires_at, ticket, ...session } = await send(path, method, body, signal);
|
|
22
|
+
return {
|
|
23
|
+
...session,
|
|
24
|
+
expiresAt: expires_at,
|
|
25
|
+
...(ticket ? { ticket: { uploadId: ticket.upload_id } } : {}),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
const readTransfer = async (path, body, signal) => {
|
|
29
|
+
const { expires_at, ...grant } = await send(path, "POST", body, signal);
|
|
30
|
+
return { ...grant, expiresAt: expires_at };
|
|
31
|
+
};
|
|
32
|
+
const cancel = (id) => readSession(`${sessionPath(id)}/cancel`, "POST");
|
|
33
|
+
return {
|
|
34
|
+
status: (id, signal) => readSession(sessionPath(id), "GET", undefined, signal),
|
|
35
|
+
cancel,
|
|
36
|
+
async uploadForAction(target, file, options = {}) {
|
|
37
|
+
const idempotencyKey = options.idempotencyKey ?? crypto.randomUUID();
|
|
38
|
+
const createIntent = target.recordId ? undefined : (target.createIntent ?? idempotencyKey);
|
|
39
|
+
const session = await readSession("/uploads", "POST", {
|
|
40
|
+
entity: toSnakeCase(target.entity),
|
|
41
|
+
property: toSnakeCase(target.property),
|
|
42
|
+
action: toSnakeCase(target.action),
|
|
43
|
+
...(target.recordId ? { record_id: target.recordId } : {}),
|
|
44
|
+
...(createIntent ? { create_intent: createIntent } : {}),
|
|
45
|
+
idempotency_key: idempotencyKey,
|
|
46
|
+
filename: file.name,
|
|
47
|
+
content_type: file.type,
|
|
48
|
+
size: file.size,
|
|
49
|
+
}, options.signal);
|
|
50
|
+
if (session.ticket) {
|
|
51
|
+
return {
|
|
52
|
+
ticket: session.ticket,
|
|
53
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
let completionStarted = false;
|
|
57
|
+
let transferRejected = false;
|
|
58
|
+
try {
|
|
59
|
+
if (session.state !== "completing") {
|
|
60
|
+
const grant = await readTransfer(`${sessionPath(session.id)}/parts`, undefined, options.signal);
|
|
61
|
+
options.onProgress?.(0, file.size);
|
|
62
|
+
const response = await transfer(grant.url, {
|
|
63
|
+
method: grant.method,
|
|
64
|
+
headers: grant.headers,
|
|
65
|
+
body: file,
|
|
66
|
+
signal: options.signal,
|
|
67
|
+
credentials: "omit",
|
|
68
|
+
referrerPolicy: "no-referrer",
|
|
69
|
+
});
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
transferRejected = true;
|
|
72
|
+
throw new Error(`File transfer failed (${response.status}).`);
|
|
73
|
+
}
|
|
74
|
+
options.onProgress?.(file.size, file.size);
|
|
75
|
+
}
|
|
76
|
+
completionStarted = true;
|
|
77
|
+
const completed = await readSession(`${sessionPath(session.id)}/complete`, "POST", undefined, options.signal);
|
|
78
|
+
if (!completed.ticket) {
|
|
79
|
+
throw new Error("The file upload is not ready.");
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
ticket: completed.ticket,
|
|
83
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (options.signal?.aborted || transferRejected) {
|
|
88
|
+
// Deliberate cancellation uses a fresh request even when the signal was aborted.
|
|
89
|
+
await cancel(session.id).catch((cause) => {
|
|
90
|
+
console.warn("ontology.upload.cancel_failed", { uploadId: session.id, cause });
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
else if (completionStarted) {
|
|
94
|
+
// A lost completion response may already have produced a valid ticket.
|
|
95
|
+
const recovered = await readSession(sessionPath(session.id), "GET").catch(() => undefined);
|
|
96
|
+
if (recovered?.ticket) {
|
|
97
|
+
return {
|
|
98
|
+
ticket: recovered.ticket,
|
|
99
|
+
options: { idempotencyKey, ...(createIntent ? { createIntent } : {}) },
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Ambiguous network failures retain the session for an explicit retry or scheduled cleanup.
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
download(entity, recordId, property, options = {}) {
|
|
108
|
+
const { signal } = options;
|
|
109
|
+
const input = {
|
|
110
|
+
...(options.revisionId ? { revision_id: options.revisionId } : {}),
|
|
111
|
+
...(options.inline === undefined ? {} : { inline: options.inline }),
|
|
112
|
+
};
|
|
113
|
+
return readTransfer(`/${encodeURIComponent(toSnakeCase(entity))}/${encodeURIComponent(recordId)}/blobs/${encodeURIComponent(toSnakeCase(property))}/download`, input, signal);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
|
@@ -27,7 +27,11 @@
|
|
|
27
27
|
"types": "./dist/ui/index.d.ts",
|
|
28
28
|
"import": "./dist/ui/index.js"
|
|
29
29
|
},
|
|
30
|
-
"./ui/styles.css": "./dist/ui/styles.css"
|
|
30
|
+
"./ui/styles.css": "./dist/ui/styles.css",
|
|
31
|
+
"./storage": {
|
|
32
|
+
"types": "./dist/storage.d.ts",
|
|
33
|
+
"import": "./dist/storage.js"
|
|
34
|
+
}
|
|
31
35
|
},
|
|
32
36
|
"files": [
|
|
33
37
|
"dist",
|