@simpleplatform/sdk 2.1.0 → 2.3.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 CHANGED
@@ -40,7 +40,7 @@ The TypeScript SDK is organized into focused modules for different capabilities:
40
40
  | **Security** | `@simpleplatform/sdk/security` | Security policy authoring |
41
41
  | **Settings** | `@simpleplatform/sdk/settings` | Application settings retrieval |
42
42
  | **Storage** | `@simpleplatform/sdk/storage` | File upload, and reading a stored file's bytes |
43
- | **Space** | `@simpleplatform/sdk/space` | Behavior-aware record workflows in a Space |
43
+ | **Space** | `@simpleplatform/sdk/space` | Records, data, tasks, and documents in a Space |
44
44
 
45
45
  ## Embedded Spaces
46
46
 
@@ -100,6 +100,10 @@ and tools. In a non-record Space, `simple.data` remains available while
100
100
  `simple.records.current()` rejects with `SpaceProtocolError` code `unavailable`
101
101
  and explains that the Space must be configured as a record view.
102
102
 
103
+ Each capability beyond `simple.data` is negotiated with the host when the Space
104
+ connects. A capability the host did not negotiate rejects with
105
+ `SpaceProtocolError` code `unavailable` when it is called, and sends nothing.
106
+
103
107
  ### Space data access
104
108
 
105
109
  Use `simple.data` for application data that is not the record form currently
@@ -131,6 +135,175 @@ Those commands preserve Record Behaviors, validation, documents, and the shared
131
135
  record state used by the platform header. `simple.data.mutate()` is for other
132
136
  authorized application data; it must not be used to bypass a record workflow.
133
137
 
138
+ ### Space tasks
139
+
140
+ `simple.tasks` creates a task from a task type and replies on a task. Tasks are
141
+ not the page's record, so they work in standalone and record Spaces alike.
142
+
143
+ ```typescript
144
+ const { task } = await simple.tasks.create({
145
+ assignedToId: 'USR000005', // Optional: defaults to the user creating the task.
146
+ input: { packet: 'DOC000001' }, // The typed input the task type declares.
147
+ taskTypeId: 'TTY000003',
148
+ title: 'Review the contract packet',
149
+ })
150
+
151
+ const { messageId, taskRevision } = await simple.tasks.reply({
152
+ content: 'The revised drawing is attached.',
153
+ inReplyToMessageId: 'MSG000006', // Optional: the message this reply answers.
154
+ taskId: task.id,
155
+ })
156
+ ```
157
+
158
+ `create()` returns `{ task: { id, status, revision } }`, where `status` is one
159
+ of `queued`, `in_progress`, `waiting`, `completed`, `cancelled`, or `failed`.
160
+ `reply()` returns the new message's ID and the task's revision after the reply.
161
+
162
+ `input` is always a JSON object; pass `{}` when the task type needs no input.
163
+ Everything inside it must be a JSON value (plain objects, arrays, strings,
164
+ finite numbers, booleans, and `null`) so it means the same thing on every
165
+ transport. `null`, an array, or a single value as the input itself is refused.
166
+ The host holds a task type's typed input to 32,768 encoded bytes and 64 levels
167
+ of nesting.
168
+
169
+ `assignedToId`, when given, must name an existing user in the tenant; left out,
170
+ the task is assigned to the user creating it.
171
+
172
+ A request the SDK can tell is incomplete is refused before it is sent, with
173
+ `SpaceProtocolError` code `invalid_request`.
174
+
175
+ When the task service refuses a create or a reply, the call rejects with
176
+ `SpaceProtocolError` code `task_rejected`, whatever the reason. Its `message` is
177
+ the service's message, and its `details` is the service's error exactly as sent:
178
+ `{ code, category, message, pointers, details }`. Neither the host nor the SDK
179
+ adds, drops, or renames anything in it. The reason is `details.code`, not
180
+ `error.code`:
181
+
182
+ ```typescript
183
+ import { SpaceProtocolError } from '@simpleplatform/sdk/space'
184
+
185
+ try {
186
+ await simple.tasks.create({ input: { amount: 700 }, taskTypeId: 'TTY000003', title: 'Open the job' })
187
+ }
188
+ catch (error) {
189
+ if (!(error instanceof SpaceProtocolError) || error.code !== 'task_rejected')
190
+ throw error
191
+
192
+ const refusal = error.details as { category: string, code: string, details: unknown, pointers: string[] }
193
+ // refusal.category says whether to correct the request or send it again,
194
+ // refusal.code says why, and refusal.pointers says where.
195
+ console.warn(refusal.category, refusal.code, refusal.pointers)
196
+ }
197
+ ```
198
+
199
+ `details.category` says whether to correct the request or send it again:
200
+
201
+ - `validation` is final. The request is wrong, and the host keeps nothing of
202
+ the refused create, so correct the request before sending it again.
203
+ - `runtime` means the task service did not answer, not that the request is
204
+ wrong. Its codes are `TASK_RUNTIME_UNAVAILABLE`, `TASK_RUNTIME_TIMEOUT`,
205
+ `TASK_RUNTIME_BUSY`, and `TASK_RUNTIME_REMOTE_FAILURE`. The host keeps the
206
+ pending create, so calling `create()` again with the same arguments resends
207
+ that create under the same task id, and a create that did land is not made
208
+ twice.
209
+
210
+ A create's title, input, and assignee are refused with these `validation`
211
+ codes:
212
+
213
+ | `details.code` | When | `details.pointers` | `details.details` |
214
+ | ----------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------- | ----------------------------------- |
215
+ | `TASK_INPUT_INVALID` | The input fails its task type's input schema | Each issue's `instance_pointer` under `/input`, once each | `{ errors, truncated }` |
216
+ | `TASK_INPUT_INVALID` | The title is longer than 255 characters, or the input is nested deeper than 64 levels | `/title` or `/input` | `{}` |
217
+ | `TASK_INPUT_TOO_LARGE` | The input encodes to more than 32,768 bytes | `/input` | `{ measured_bytes, allowed_bytes }` |
218
+ | `TASK_ASSIGNEE_INVALID` | `assignedToId` is not a user in the tenant | `/assigned_to_id` | `{}` |
219
+
220
+ For a schema failure, `errors` holds at most 50 issues sorted by
221
+ `instance_pointer`, and `truncated` is `true` when there were more. Each issue
222
+ is exactly `{ code, instance_pointer, schema_pointer }` and never carries the
223
+ value that failed. `instance_pointer` is relative to the input, while
224
+ `pointers` carries the `/input` prefix. `schema_pointer` is the location in the
225
+ task type's input schema of the object holding the keyword that failed.
226
+
227
+ For example, a task type whose input schema is
228
+ `{ type: 'object', properties: { amount: { type: 'integer' } }, required: ['job_id'], additionalProperties: false }`
229
+ refuses the input `{ amount: 'seven hundred', note: 'a private note' }` with
230
+ this `error.details`:
231
+
232
+ ```json
233
+ {
234
+ "code": "TASK_INPUT_INVALID",
235
+ "category": "validation",
236
+ "message": "The task input is invalid.",
237
+ "pointers": ["/input", "/input/amount", "/input/note"],
238
+ "details": {
239
+ "errors": [
240
+ { "code": "required", "instance_pointer": "", "schema_pointer": "" },
241
+ { "code": "type", "instance_pointer": "/amount", "schema_pointer": "/properties/amount" },
242
+ { "code": "boolean_schema", "instance_pointer": "/note", "schema_pointer": "/additionalProperties" }
243
+ ],
244
+ "truncated": false
245
+ }
246
+ }
247
+ ```
248
+
249
+ A `required` issue points at the object that lacks the member, not at the
250
+ member: above it sits at `/input` and does not name `job_id`.
251
+
252
+ The input `{ notes }`, where `notes` holds 32,769 characters and the input
253
+ encodes to 32,781 bytes, is refused with:
254
+
255
+ ```json
256
+ {
257
+ "code": "TASK_INPUT_TOO_LARGE",
258
+ "category": "validation",
259
+ "message": "The task input is too large. Shorten the request or split the work into more than one task.",
260
+ "pointers": ["/input"],
261
+ "details": { "measured_bytes": 32781, "allowed_bytes": 32768 }
262
+ }
263
+ ```
264
+
265
+ An assignee who is not a user in the tenant is refused with:
266
+
267
+ ```json
268
+ {
269
+ "code": "TASK_ASSIGNEE_INVALID",
270
+ "category": "validation",
271
+ "message": "The task assignee is invalid.",
272
+ "pointers": ["/assigned_to_id"],
273
+ "details": {}
274
+ }
275
+ ```
276
+
277
+ Pointers name the members of the task service's command, not the SDK's
278
+ arguments, so the assignee is `/assigned_to_id` rather than `assignedToId`. A
279
+ refused `reply()` arrives the same way, with the service's own code in
280
+ `details.code` and the same categories: after a `runtime` refusal, calling
281
+ `reply()` again with the same arguments resends the kept message rather than
282
+ posting it twice.
283
+
284
+ ### Space documents
285
+
286
+ `simple.documents.stage()` stores a file without attaching it to any record and
287
+ returns its handle. The host uploads it, so a large file never travels inside
288
+ an action request. The file's bytes are transferred to the host, not copied.
289
+
290
+ ```typescript
291
+ const picker = document.querySelector<HTMLInputElement>('#packet')!
292
+ const { handle } = await simple.documents.stage({ file: picker.files![0] })
293
+
294
+ // A plain Blob has no name of its own, so it needs one.
295
+ await simple.documents.stage({
296
+ file: new Blob([csv]),
297
+ mimeType: 'text/csv',
298
+ name: 'quantities.csv',
299
+ })
300
+ ```
301
+
302
+ The handle is `{ file_hash, filename, mime_type, size, storage_path, scope? }`.
303
+ `name` defaults to a `File`'s own name, and `mimeType` to the file's type, then
304
+ to `application/octet-stream`. A staged document is not attached to anything
305
+ yet; attach its handle through the workflow that owns the record.
306
+
134
307
  ---
135
308
 
136
309
  ## API Documentation
package/dist/ai.d.ts CHANGED
@@ -65,6 +65,52 @@ export interface JSONSchemaArray extends JSONSchemaBase {
65
65
  * This provides developers with precise autocompletion and type-checking.
66
66
  */
67
67
  export type JSONSchema = JSONSchemaArray | JSONSchemaBoolean | JSONSchemaNumber | JSONSchemaObject | JSONSchemaString;
68
+ /**
69
+ * A stored file handed to an AI operation, together with what should be done
70
+ * with it.
71
+ *
72
+ * A PDF travels to the model as a PDF, because the model reads one: its tables,
73
+ * its layout and its figures survive the trip, and none of them survive being
74
+ * turned into text. Asking for text is therefore opt-in.
75
+ *
76
+ * The page range and `deliver_as` are two questions, asked in that order. The
77
+ * range says WHICH DOCUMENT the call is about: those pages are taken out of
78
+ * the PDF first, and everything after that is about them and nothing else.
79
+ * `deliver_as` then says how that document should reach the model.
80
+ *
81
+ * Refused, before the file is read or anything is spent:
82
+ * - `first_page` or `last_page` on anything that is not a PDF;
83
+ * - one of the pair without the other, a `first_page` below 1, a `last_page`
84
+ * before `first_page`, or either one not a whole number;
85
+ * - a range that runs past the end of the document;
86
+ * - `deliver_as: 'text'` on an image, which is not read as text;
87
+ * - `deliver_as: 'document'` on a Word, Excel, PowerPoint, CSV, RTF or text
88
+ * file,
89
+ * which only ever travels as text.
90
+ *
91
+ * A page the platform cannot read as text sends the pages asked for as a PDF
92
+ * instead, so an answer is never built on text with a hole in it.
93
+ *
94
+ * Pages asked for as text arrive numbered from 1, because by then they are a
95
+ * document of their own. A line above them says which pages of which document
96
+ * they were, rather than the numbers being rewritten inside the text: a page
97
+ * number printed in a header or a cross-reference cannot be told apart from a
98
+ * page label, so editing them would corrupt the document's own words.
99
+ */
100
+ export type AIDocumentInput = DocumentHandle & {
101
+ /** The first page to use, counted from 1. Named with `last_page`. PDF only. */
102
+ first_page?: number;
103
+ /** The last page to use, included. Named with `first_page`. PDF only. */
104
+ last_page?: number;
105
+ /**
106
+ * How the document reaches the model: `'document'`, the default, sends the
107
+ * document itself; `'text'` sends the text the platform reads out of it.
108
+ *
109
+ * Both answers are sayable, so the default can be written down rather than
110
+ * left to the absence of a key.
111
+ */
112
+ deliver_as?: 'document' | 'text';
113
+ };
68
114
  /**
69
115
  * A set of common configuration options shared across all AI operations.
70
116
  * This adheres to the DRY principle, ensuring a consistent API surface.
@@ -200,22 +246,26 @@ export interface AIExecutionResult {
200
246
  * Extracts structured data from a given input using the Simple AI engine.
201
247
  *
202
248
  * @param input The source data for the extraction (string, document handle, or object).
249
+ * A document handle may carry `deliver_as`, `first_page` and `last_page`; see
250
+ * `AIDocumentInput`.
203
251
  * @param options The configuration for the extraction operation.
204
252
  * @param context The execution context provided by the host.
205
253
  * @returns A promise that resolves to an `AIExecutionResult` object.
206
254
  * @throws Will throw an error if the operation fails or inputs are invalid.
207
255
  */
208
- export declare function extract(input: DocumentHandle | object | string, options: AIExtractOptions, context: Context): Promise<AIExecutionResult>;
256
+ export declare function extract(input: AIDocumentInput | DocumentHandle | object | string, options: AIExtractOptions, context: Context): Promise<AIExecutionResult>;
209
257
  /**
210
258
  * Generates a summary for a given input using the Simple AI engine.
211
259
  *
212
260
  * @param input The source data for the summarization (string, document handle, or object).
261
+ * A document handle may carry `deliver_as`, `first_page` and `last_page`; see
262
+ * `AIDocumentInput`.
213
263
  * @param options The configuration for the summarization operation.
214
264
  * @param context The execution context provided by the host.
215
265
  * @returns A promise that resolves to an `AIExecutionResult` object containing the summary.
216
266
  * @throws Will throw an error if the operation fails or inputs are invalid.
217
267
  */
218
- export declare function summarize(input: DocumentHandle | object | string, options: AISummarizeOptions, context: Context): Promise<AIExecutionResult>;
268
+ export declare function summarize(input: AIDocumentInput | DocumentHandle | object | string, options: AISummarizeOptions, context: Context): Promise<AIExecutionResult>;
219
269
  /**
220
270
  * Transcribes audio or video from a document handle using the Simple AI engine.
221
271
  *
package/dist/ai.js CHANGED
@@ -5,8 +5,9 @@ import { execute as hostExecute } from './host';
5
5
  /**
6
6
  * Recursively processes an object to detect and upload pending files.
7
7
  * When a pending DocumentHandle is detected (has `pending: true` and `file_hash`),
8
- * it calls the ephemeral upload host function and replaces the pending handle
9
- * with the ephemeral handle returned from the upload.
8
+ * it calls the ephemeral upload host function and hands the operation the
9
+ * stored handle the upload answered with, carrying over the keys the caller
10
+ * put on the handle.
10
11
  *
11
12
  * @internal
12
13
  */
@@ -19,7 +20,23 @@ async function _uploadPendingFiles(obj, context) {
19
20
  if (!response.ok) {
20
21
  throw new Error(response.error?.message || 'Failed to upload pending file');
21
22
  }
22
- return response.data;
23
+ // The upload answers with the stored file's own reference: where it is,
24
+ // what it hashes to, its name, its type and its size. Every one of those
25
+ // keys describes the file, so every one of them is taken from the upload
26
+ // and none is carried over — a value from before the upload would name a
27
+ // file that is no longer the one being read.
28
+ //
29
+ // Everything else on the handle is the caller's: how the file is to be
30
+ // sent, which of its pages, and any key added to a file reference later.
31
+ // Those describe the request, not the file, and the upload knows nothing
32
+ // about them, so they are carried over. A caller reads a pending file and
33
+ // a stored one the same way.
34
+ //
35
+ // `pending` is the one key that is neither: it said the bytes had not been
36
+ // stored yet. They have been now, so it is dropped. A handle that still
37
+ // called itself pending would be uploaded a second time on the next pass.
38
+ const { pending: _pending, ...caller } = obj;
39
+ return { ...caller, ...response.data };
23
40
  }
24
41
  if (Array.isArray(obj)) {
25
42
  return Promise.all(obj.map(item => _uploadPendingFiles(item, context)));
@@ -82,6 +99,8 @@ async function _executeAIOperation(operation, input, options, context) {
82
99
  * Extracts structured data from a given input using the Simple AI engine.
83
100
  *
84
101
  * @param input The source data for the extraction (string, document handle, or object).
102
+ * A document handle may carry `deliver_as`, `first_page` and `last_page`; see
103
+ * `AIDocumentInput`.
85
104
  * @param options The configuration for the extraction operation.
86
105
  * @param context The execution context provided by the host.
87
106
  * @returns A promise that resolves to an `AIExecutionResult` object.
@@ -105,6 +124,8 @@ export async function extract(input, options, context) {
105
124
  * Generates a summary for a given input using the Simple AI engine.
106
125
  *
107
126
  * @param input The source data for the summarization (string, document handle, or object).
127
+ * A document handle may carry `deliver_as`, `first_page` and `last_page`; see
128
+ * `AIDocumentInput`.
108
129
  * @param options The configuration for the summarization operation.
109
130
  * @param context The execution context provided by the host.
110
131
  * @returns A promise that resolves to an `AIExecutionResult` object containing the summary.
@@ -1,3 +1,4 @@
1
+ import type { DocumentHandle } from '../types.js';
1
2
  export declare const PROTOCOL_VERSION: 1;
2
3
  export type SpaceContext = {
3
4
  applicationId: string;
@@ -29,6 +30,66 @@ export interface RecordSnapshot {
29
30
  values: Readonly<Record<string, unknown>>;
30
31
  }
31
32
  export type GraphQLVariables = Readonly<Record<string, unknown>>;
33
+ /** A value that survives a JSON round trip unchanged. */
34
+ export type JsonValue = boolean | JsonObject | null | number | string | readonly JsonValue[];
35
+ /** A JSON object: not `null`, not an array, and not a single value. */
36
+ export interface JsonObject {
37
+ readonly [key: string]: JsonValue;
38
+ }
39
+ /** The platform-standard status of a task. */
40
+ export type TaskStatus = 'cancelled' | 'completed' | 'failed' | 'in_progress' | 'queued' | 'waiting';
41
+ export interface TaskCreateInput {
42
+ /** The user the task is assigned to. Omit it to assign the task to its creator. */
43
+ assignedToId?: string;
44
+ /**
45
+ * The typed input the task type declares for its tasks. It is always a JSON
46
+ * object, even when the task type needs nothing: then it is `{}`.
47
+ */
48
+ input: JsonObject;
49
+ /** The ID of the task type the task is created from. */
50
+ taskTypeId: string;
51
+ title: string;
52
+ }
53
+ export interface TaskCreateResult {
54
+ task: {
55
+ id: string;
56
+ revision: number;
57
+ status: TaskStatus;
58
+ };
59
+ }
60
+ export interface TaskReplyInput {
61
+ content: string;
62
+ /** The message this reply answers, when it answers one. */
63
+ inReplyToMessageId?: string;
64
+ taskId: string;
65
+ }
66
+ export interface TaskReplyResult {
67
+ messageId: string;
68
+ /** The task's revision after the reply was recorded. */
69
+ taskRevision: number;
70
+ }
71
+ export interface SimpleTasksClient {
72
+ create: (task: TaskCreateInput) => Promise<TaskCreateResult>;
73
+ reply: (reply: TaskReplyInput) => Promise<TaskReplyResult>;
74
+ }
75
+ /** A stored file that is not yet attached to any record. */
76
+ export interface StagedDocumentHandle extends DocumentHandle {
77
+ scope?: 'ephemeral' | 'record' | 'staged';
78
+ }
79
+ export interface DocumentStageInput {
80
+ /** The file to stage. Its bytes are transferred to the host, not copied. */
81
+ file: Blob | File;
82
+ /** Defaults to the file's own type, then to `application/octet-stream`. */
83
+ mimeType?: string;
84
+ /** Defaults to the name of a `File`. A plain `Blob` has none, so it needs one. */
85
+ name?: string;
86
+ }
87
+ export interface DocumentStageResult {
88
+ handle: StagedDocumentHandle;
89
+ }
90
+ export interface SimpleDocumentsClient {
91
+ stage: (document: DocumentStageInput) => Promise<DocumentStageResult>;
92
+ }
32
93
  export interface SpaceDataTransport {
33
94
  execute: <TResult = unknown>(document: string, variables?: GraphQLVariables) => Promise<TResult>;
34
95
  }
@@ -45,9 +106,11 @@ export interface RecordHandle {
45
106
  export interface SimpleClient {
46
107
  context: SpaceContext;
47
108
  data: SimpleDataClient;
109
+ documents: SimpleDocumentsClient;
48
110
  records: {
49
111
  current: () => Promise<RecordHandle>;
50
112
  };
113
+ tasks: SimpleTasksClient;
51
114
  }
52
115
  export interface SpaceProtocolErrorPayload {
53
116
  code: string;
@@ -104,7 +167,30 @@ export interface RecordSubmitResult {
104
167
  ok: boolean;
105
168
  snapshot: RecordSnapshot;
106
169
  }
107
- export type ProtocolRequest = CurrentRecordRequest | RecordSubmitRequest | RecordUpdateRequest;
170
+ export interface TaskCreateRequest {
171
+ operation: 'task.create';
172
+ payload: TaskCreateInput;
173
+ protocol: typeof PROTOCOL_VERSION;
174
+ requestId: string;
175
+ }
176
+ export interface TaskReplyRequest {
177
+ operation: 'task.reply';
178
+ payload: TaskReplyInput;
179
+ protocol: typeof PROTOCOL_VERSION;
180
+ requestId: string;
181
+ }
182
+ export interface DocumentStageRequest {
183
+ operation: 'document.stage';
184
+ payload: {
185
+ /** Transferred with the request, so it is detached in the Space afterwards. */
186
+ bytes: ArrayBuffer;
187
+ mimeType: string;
188
+ name: string;
189
+ };
190
+ protocol: typeof PROTOCOL_VERSION;
191
+ requestId: string;
192
+ }
193
+ export type ProtocolRequest = CurrentRecordRequest | DocumentStageRequest | RecordSubmitRequest | RecordUpdateRequest | TaskCreateRequest | TaskReplyRequest;
108
194
  export interface ProtocolSuccessResponse<TResult> {
109
195
  ok: true;
110
196
  protocol: typeof PROTOCOL_VERSION;
@@ -119,12 +205,22 @@ export interface ProtocolErrorResponse {
119
205
  }
120
206
  export type ProtocolResponse<TResult> = ProtocolErrorResponse | ProtocolSuccessResponse<TResult>;
121
207
  export interface SpaceTransport {
122
- request: <TResult>(request: ProtocolRequest) => Promise<ProtocolResponse<TResult>>;
208
+ /**
209
+ * `transfer` names buffers inside the request whose ownership moves to the
210
+ * host with it instead of being copied. A transport that cannot transfer
211
+ * sends them by value.
212
+ */
213
+ request: <TResult>(request: ProtocolRequest, transfer?: ArrayBuffer[]) => Promise<ProtocolResponse<TResult>>;
123
214
  }
124
215
  export interface SimpleClientOptions {
125
216
  context?: SpaceContext;
126
217
  dataTransport?: SpaceDataTransport;
218
+ /** Present only when the host negotiated the document protocol. */
219
+ documentTransport?: SpaceTransport;
127
220
  nextRequestId?: () => string;
221
+ /** Present only when the host negotiated the task protocol. */
222
+ taskTransport?: SpaceTransport;
223
+ /** Present only when the host negotiated the record protocol. */
128
224
  transport?: SpaceTransport;
129
225
  }
130
226
  /**
@@ -134,5 +230,5 @@ export interface SimpleClientOptions {
134
230
  * contract can be exercised in a first-party direct adapter and in any UI
135
231
  * framework without importing browser-specific code.
136
232
  */
137
- export declare function createSimpleClient({ context, dataTransport, nextRequestId, transport, }: SimpleClientOptions): SimpleClient;
233
+ export declare function createSimpleClient({ context, dataTransport, documentTransport, nextRequestId, taskTransport, transport, }: SimpleClientOptions): SimpleClient;
138
234
  export declare function isSpaceContext(value: unknown): value is SpaceContext;
@@ -34,7 +34,7 @@ export class SpaceDataError extends Error {
34
34
  * contract can be exercised in a first-party direct adapter and in any UI
35
35
  * framework without importing browser-specific code.
36
36
  */
37
- export function createSimpleClient({ context = { kind: 'standalone' }, dataTransport, nextRequestId = createRequestId, transport, }) {
37
+ export function createSimpleClient({ context = { kind: 'standalone' }, dataTransport, documentTransport, nextRequestId = createRequestId, taskTransport, transport, }) {
38
38
  const immutableContext = immutableSpaceContext(context);
39
39
  return {
40
40
  context: immutableContext,
@@ -42,6 +42,7 @@ export function createSimpleClient({ context = { kind: 'standalone' }, dataTrans
42
42
  mutate: (document, variables) => executeData(dataTransport, document, variables),
43
43
  query: (document, variables) => executeData(dataTransport, document, variables),
44
44
  },
45
+ documents: createDocumentsClient(documentTransport, nextRequestId),
45
46
  records: {
46
47
  async current() {
47
48
  if (immutableContext.kind !== 'record') {
@@ -70,6 +71,7 @@ export function createSimpleClient({ context = { kind: 'standalone' }, dataTrans
70
71
  return new ProtocolRecordHandle(result, nextRequestId, transport);
71
72
  },
72
73
  },
74
+ tasks: createTasksClient(taskTransport, nextRequestId),
73
75
  };
74
76
  }
75
77
  export function isSpaceContext(value) {
@@ -97,6 +99,83 @@ function executeData(dataTransport, document, variables) {
97
99
  }
98
100
  return dataTransport.execute(document, variables);
99
101
  }
102
+ /**
103
+ * A staged document is stored without being attached to a record, so staging
104
+ * is available in any Space whose host negotiated the document protocol.
105
+ */
106
+ function createDocumentsClient(transport, nextRequestId) {
107
+ return {
108
+ async stage(document) {
109
+ if (!transport) {
110
+ throw new SpaceProtocolError({
111
+ code: 'unavailable',
112
+ message: 'Documents are unavailable because the Space host did not negotiate the document protocol.',
113
+ });
114
+ }
115
+ const { file, mimeType, name } = readDocumentStageInput(document);
116
+ // A Blob cannot itself be transferred. Its bytes are read into a buffer
117
+ // once, and that buffer is handed to the host rather than copied again.
118
+ const bytes = await file.arrayBuffer();
119
+ const request = {
120
+ operation: 'document.stage',
121
+ payload: { bytes, mimeType, name },
122
+ protocol: PROTOCOL_VERSION,
123
+ requestId: nextRequestId(),
124
+ };
125
+ const response = await transport.request(request, [bytes]);
126
+ const result = readResponse(response, request);
127
+ if (!isDocumentStageResult(result))
128
+ throw invalidResponse('The document-stage response is malformed.');
129
+ return { handle: { ...result.handle } };
130
+ },
131
+ };
132
+ }
133
+ /**
134
+ * Tasks are not tied to a page record, so they are available in any Space
135
+ * whose host negotiated the task protocol, standalone or record.
136
+ */
137
+ function createTasksClient(transport, nextRequestId) {
138
+ const requireTransport = () => {
139
+ if (!transport) {
140
+ throw new SpaceProtocolError({
141
+ code: 'unavailable',
142
+ message: 'Tasks are unavailable because the Space host did not negotiate the task protocol.',
143
+ });
144
+ }
145
+ return transport;
146
+ };
147
+ return {
148
+ async create(task) {
149
+ const taskTransport = requireTransport();
150
+ const request = {
151
+ operation: 'task.create',
152
+ payload: readTaskCreatePayload(task),
153
+ protocol: PROTOCOL_VERSION,
154
+ requestId: nextRequestId(),
155
+ };
156
+ const response = await taskTransport.request(request);
157
+ const result = readResponse(response, request);
158
+ if (!isTaskCreateResult(result))
159
+ throw invalidResponse('The task-create response is malformed.');
160
+ const { id, revision, status } = result.task;
161
+ return { task: { id, revision, status } };
162
+ },
163
+ async reply(reply) {
164
+ const taskTransport = requireTransport();
165
+ const request = {
166
+ operation: 'task.reply',
167
+ payload: readTaskReplyPayload(reply),
168
+ protocol: PROTOCOL_VERSION,
169
+ requestId: nextRequestId(),
170
+ };
171
+ const response = await taskTransport.request(request);
172
+ const result = readResponse(response, request);
173
+ if (!isTaskReplyResult(result))
174
+ throw invalidResponse('The task-reply response is malformed.');
175
+ return { messageId: result.messageId, taskRevision: result.taskRevision };
176
+ },
177
+ };
178
+ }
100
179
  class ProtocolRecordHandle {
101
180
  constructor({ sessionId, snapshot }, nextRequestId, transport) {
102
181
  _ProtocolRecordHandle_nextRequestId.set(this, void 0);
@@ -168,6 +247,137 @@ function deepFreeze(value) {
168
247
  function invalidResponse(message) {
169
248
  return new SpaceProtocolError({ code: 'invalid_response', message });
170
249
  }
250
+ function invalidRequest(message) {
251
+ return new SpaceProtocolError({ code: 'invalid_request', message });
252
+ }
253
+ /**
254
+ * Checks a task before it is sent, so a plain-JavaScript caller learns what is
255
+ * wrong without a host round trip. The payload names only the members the
256
+ * operation defines; an optional member left out is not sent at all.
257
+ */
258
+ function readTaskCreatePayload(task) {
259
+ if (!isObjectRecord(task))
260
+ throw invalidRequest('A task needs a title, a task type, and its input.');
261
+ if (!isNonBlankString(task.title))
262
+ throw invalidRequest('A task needs a title.');
263
+ if (!isNonBlankString(task.taskTypeId))
264
+ throw invalidRequest('A task needs the ID of its task type.');
265
+ if (!isJsonObject(task.input))
266
+ throw invalidRequest('A task input must be a JSON object, and everything in it a JSON value.');
267
+ if (task.assignedToId !== undefined && !isNonBlankString(task.assignedToId))
268
+ throw invalidRequest('A task assignee must be a user ID.');
269
+ return {
270
+ ...(task.assignedToId === undefined ? {} : { assignedToId: task.assignedToId }),
271
+ input: task.input,
272
+ taskTypeId: task.taskTypeId,
273
+ title: task.title,
274
+ };
275
+ }
276
+ function readTaskReplyPayload(reply) {
277
+ if (!isObjectRecord(reply))
278
+ throw invalidRequest('A task reply needs a task ID and its content.');
279
+ if (!isNonBlankString(reply.taskId))
280
+ throw invalidRequest('A task reply needs the ID of its task.');
281
+ if (!isNonBlankString(reply.content))
282
+ throw invalidRequest('A task reply needs content.');
283
+ if (reply.inReplyToMessageId !== undefined && !isNonBlankString(reply.inReplyToMessageId))
284
+ throw invalidRequest('A task reply can answer only a message ID.');
285
+ return {
286
+ content: reply.content,
287
+ ...(reply.inReplyToMessageId === undefined ? {} : { inReplyToMessageId: reply.inReplyToMessageId }),
288
+ taskId: reply.taskId,
289
+ };
290
+ }
291
+ /**
292
+ * The task.create contract takes an object for input, never null, an array, or
293
+ * a single value, so a caller learns that here instead of from the host.
294
+ */
295
+ function isJsonObject(value) {
296
+ return isObjectRecord(value) && isJsonValue(value);
297
+ }
298
+ /**
299
+ * Accepts only what JSON carries unchanged. A structured clone would pass a
300
+ * Date, Map, or class instance through a MessagePort and a JSON transport
301
+ * would not, so the task input is held to JSON on every transport.
302
+ */
303
+ function isJsonValue(value, ancestors = new Set()) {
304
+ if (value === null || typeof value === 'boolean' || typeof value === 'string')
305
+ return true;
306
+ if (typeof value === 'number')
307
+ return Number.isFinite(value);
308
+ if (!value || typeof value !== 'object' || ancestors.has(value))
309
+ return false;
310
+ const prototype = Object.getPrototypeOf(value);
311
+ if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null)
312
+ return false;
313
+ ancestors.add(value);
314
+ const valid = Object.values(value).every(child => isJsonValue(child, ancestors));
315
+ ancestors.delete(value);
316
+ return valid;
317
+ }
318
+ function readDocumentStageInput(document) {
319
+ if (!isObjectRecord(document) || !isBlob(document.file))
320
+ throw invalidRequest('A staged document needs a File or Blob.');
321
+ const name = document.name ?? readFileName(document.file);
322
+ if (!isNonBlankString(name))
323
+ throw invalidRequest('A staged document needs a name, and a Blob has none of its own.');
324
+ if (document.mimeType !== undefined && typeof document.mimeType !== 'string')
325
+ throw invalidRequest('A staged document type must be a MIME type.');
326
+ return {
327
+ file: document.file,
328
+ mimeType: document.mimeType || document.file.type || 'application/octet-stream',
329
+ name,
330
+ };
331
+ }
332
+ /** Duck-typed so a Blob from another realm, or a test double, is accepted. */
333
+ function isBlob(value) {
334
+ if (!isObjectRecord(value))
335
+ return false;
336
+ return typeof value.arrayBuffer === 'function' && typeof value.size === 'number' && typeof value.type === 'string';
337
+ }
338
+ function readFileName(file) {
339
+ const { name } = file;
340
+ return typeof name === 'string' ? name : undefined;
341
+ }
342
+ function isNonBlankString(value) {
343
+ return typeof value === 'string' && value.trim().length > 0;
344
+ }
345
+ function isNonNegativeInteger(value) {
346
+ return Number.isSafeInteger(value) && value >= 0;
347
+ }
348
+ const DOCUMENT_SCOPES = new Set([
349
+ 'ephemeral',
350
+ 'record',
351
+ 'staged',
352
+ ]);
353
+ function isDocumentStageResult(value) {
354
+ if (!isObjectRecord(value) || !isObjectRecord(value.handle))
355
+ return false;
356
+ const handle = value.handle;
357
+ return isNonBlankString(handle.file_hash)
358
+ && isNonBlankString(handle.filename)
359
+ && typeof handle.mime_type === 'string'
360
+ && isNonNegativeInteger(handle.size)
361
+ && isNonBlankString(handle.storage_path)
362
+ && (handle.scope === undefined || DOCUMENT_SCOPES.has(handle.scope));
363
+ }
364
+ const TASK_STATUSES = new Set([
365
+ 'cancelled',
366
+ 'completed',
367
+ 'failed',
368
+ 'in_progress',
369
+ 'queued',
370
+ 'waiting',
371
+ ]);
372
+ function isTaskCreateResult(value) {
373
+ if (!isObjectRecord(value) || !isObjectRecord(value.task))
374
+ return false;
375
+ const { id, revision, status } = value.task;
376
+ return isNonBlankString(id) && isNonNegativeInteger(revision) && TASK_STATUSES.has(status);
377
+ }
378
+ function isTaskReplyResult(value) {
379
+ return isObjectRecord(value) && isNonBlankString(value.messageId) && isNonNegativeInteger(value.taskRevision);
380
+ }
171
381
  function isCurrentRecordResult(value) {
172
382
  if (!value || typeof value !== 'object')
173
383
  return false;
@@ -1,12 +1,12 @@
1
1
  import type { SimpleClient } from './core.js';
2
2
  import { SpaceDataError, SpaceProtocolError } from './core.js';
3
3
  export { SpaceDataError, SpaceProtocolError, };
4
- export type { GraphQLVariables, RecordErrorSnapshot, RecordFieldSnapshot, RecordFormError, RecordHandle, RecordSnapshot, RecordSubmitResult, RecordUpdateResult, SimpleClient, SimpleDataClient, SpaceContext, SpaceDataErrorPayload, SpaceProtocolErrorPayload, } from './core.js';
4
+ export type { DocumentStageInput, DocumentStageResult, GraphQLVariables, JsonObject, JsonValue, RecordErrorSnapshot, RecordFieldSnapshot, RecordFormError, RecordHandle, RecordSnapshot, RecordSubmitResult, RecordUpdateResult, SimpleClient, SimpleDataClient, SimpleDocumentsClient, SimpleTasksClient, SpaceContext, SpaceDataErrorPayload, SpaceProtocolErrorPayload, StagedDocumentHandle, TaskCreateInput, TaskCreateResult, TaskReplyInput, TaskReplyResult, TaskStatus, } from './core.js';
5
5
  interface MessagePortLike {
6
6
  onmessage: null | ((event: {
7
7
  data: unknown;
8
8
  }) => void);
9
- postMessage: (message: unknown) => void;
9
+ postMessage: (message: unknown, transfer?: ArrayBuffer[]) => void;
10
10
  start?: () => void;
11
11
  }
12
12
  export interface SpaceWindowLike {
@@ -27,7 +27,8 @@ export interface ConnectSpaceOptions {
27
27
  }
28
28
  /**
29
29
  * Connects any embedded Space to its parent through the dedicated MessagePort
30
- * handshake. Record operations become available only when the host negotiates
31
- * record protocol v1 for a configured record view.
30
+ * handshake. Each protocol capability is offered in `SPACE_READY` and becomes
31
+ * available only when the host negotiates it in `INIT_RPC`: record operations
32
+ * for a configured record view, and task and document operations in any Space.
32
33
  */
33
34
  export declare function connectSpace({ targetOrigin, window }: ConnectSpaceOptions): Promise<SimpleClient>;
@@ -2,8 +2,9 @@ import { createSimpleClient, isSpaceContext, PROTOCOL_VERSION, SpaceDataError, S
2
2
  export { SpaceDataError, SpaceProtocolError, };
3
3
  /**
4
4
  * Connects any embedded Space to its parent through the dedicated MessagePort
5
- * handshake. Record operations become available only when the host negotiates
6
- * record protocol v1 for a configured record view.
5
+ * handshake. Each protocol capability is offered in `SPACE_READY` and becomes
6
+ * available only when the host negotiates it in `INIT_RPC`: record operations
7
+ * for a configured record view, and task and document operations in any Space.
7
8
  */
8
9
  export function connectSpace({ targetOrigin, window = globalThis.window }) {
9
10
  if (!window) {
@@ -33,18 +34,22 @@ export function connectSpace({ targetOrigin, window = globalThis.window }) {
33
34
  return;
34
35
  }
35
36
  const transport = createMessagePortTransport(port);
36
- const recordTransport = event.data.protocols?.record === PROTOCOL_VERSION
37
- ? transport
38
- : undefined;
37
+ const protocols = event.data.protocols;
39
38
  resolve(createSimpleClient({
40
39
  context: event.data.context,
41
40
  dataTransport: transport,
42
- transport: recordTransport,
41
+ documentTransport: protocols?.document === PROTOCOL_VERSION ? transport : undefined,
42
+ taskTransport: protocols?.task === PROTOCOL_VERSION ? transport : undefined,
43
+ transport: protocols?.record === PROTOCOL_VERSION ? transport : undefined,
43
44
  }));
44
45
  };
45
46
  window.addEventListener('message', onMessage);
46
47
  window.parent.postMessage({
47
- protocols: { record: [PROTOCOL_VERSION] },
48
+ protocols: {
49
+ document: [PROTOCOL_VERSION],
50
+ record: [PROTOCOL_VERSION],
51
+ task: [PROTOCOL_VERSION],
52
+ },
48
53
  type: 'SPACE_READY',
49
54
  }, targetOrigin);
50
55
  });
@@ -94,12 +99,12 @@ function createMessagePortTransport(port) {
94
99
  });
95
100
  });
96
101
  },
97
- request: (request) => {
102
+ request: (request, transfer = []) => {
98
103
  return new Promise((resolve) => {
99
104
  pending.set(request.requestId, {
100
105
  resolve: response => resolve(response),
101
106
  });
102
- port.postMessage({ request, type: 'SPACE_PROTOCOL_REQUEST' });
107
+ port.postMessage({ request, type: 'SPACE_PROTOCOL_REQUEST' }, transfer);
103
108
  });
104
109
  },
105
110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simpleplatform/sdk",
3
- "version": "2.1.0",
3
+ "version": "2.3.0",
4
4
  "description": "Simple Platform Typescript SDK",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://docs.simple.dev",