@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 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";
@@ -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
- (action === "link" || action === "unlink") &&
237
- field === "relationship" &&
238
- typeof child === "string"
239
- ? toSnakeCase(child)
240
- : selectToWire(child, property),
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
  }
@@ -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
- : { options: { validate_only: options.validateOnly } }),
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
+ };
@@ -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.7.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",