@segmentstream-cloud/app 0.7.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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/ontology-wire.js +7 -5
- package/dist/ontology.d.ts +2 -0
- package/dist/ontology.js +14 -2
- package/dist/storage.d.ts +54 -0
- package/dist/storage.js +116 -0
- package/package.json +6 -2
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/ontology-wire.js
CHANGED
|
@@ -233,11 +233,13 @@ export function actionInputToWire(input, entity, action, catalog) {
|
|
|
233
233
|
const property = definition?.properties.find((candidate) => candidate.name === wireField);
|
|
234
234
|
return [
|
|
235
235
|
wireField,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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),
|
|
241
243
|
];
|
|
242
244
|
}));
|
|
243
245
|
}
|
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;
|
package/dist/ontology.js
CHANGED
|
@@ -89,9 +89,21 @@ function entityClient(entity, transport, getCatalog) {
|
|
|
89
89
|
const catalog = await getCatalog();
|
|
90
90
|
const wireResponse = await send(transport, "POST", `${base}/actions/${encodeURIComponent(action)}`, {
|
|
91
91
|
input: actionInputToWire(input, entity, action, catalog),
|
|
92
|
-
...(options.validateOnly === undefined
|
|
92
|
+
...([options.validateOnly, options.idempotencyKey, options.createIntent].every((value) => value === undefined)
|
|
93
93
|
? {}
|
|
94
|
-
: {
|
|
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
|
+
}),
|
|
95
107
|
}, options.signal);
|
|
96
108
|
const response = { ...wireResponse, edits: editsFromWire(wireResponse.edits, catalog) };
|
|
97
109
|
if (action === "create" || action === "update") {
|
|
@@ -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",
|