@simpleplatform/sdk 2.0.1 → 2.2.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
@@ -39,7 +39,7 @@ The TypeScript SDK is organized into focused modules for different capabilities:
39
39
  | **HTTP** | `@simpleplatform/sdk/http` | External HTTP requests |
40
40
  | **Security** | `@simpleplatform/sdk/security` | Security policy authoring |
41
41
  | **Settings** | `@simpleplatform/sdk/settings` | Application settings retrieval |
42
- | **Storage** | `@simpleplatform/sdk/storage` | File upload and management |
42
+ | **Storage** | `@simpleplatform/sdk/storage` | File upload, and reading a stored file's bytes |
43
43
  | **Space** | `@simpleplatform/sdk/space` | Behavior-aware record workflows in a Space |
44
44
 
45
45
  ## Embedded Spaces
@@ -274,8 +274,23 @@ const response = await http.fetch(
274
274
  },
275
275
  request.context
276
276
  )
277
+
278
+ // A browser request that carries the page's cookies
279
+ const session = await http.fetch(
280
+ {
281
+ credentials: 'include',
282
+ url: 'https://api.example.com/session'
283
+ },
284
+ request.context
285
+ )
277
286
  ```
278
287
 
288
+ `credentials` is the browser's credentials mode for the request: `'omit'`,
289
+ `'same-origin'` or `'include'`. A request that names none sends none, and the
290
+ browser host then omits credentials. A cross-origin request that includes them
291
+ still needs the endpoint to allow the page's origin and credentials through
292
+ CORS. The server host has no browser credentials and ignores the option.
293
+
279
294
  ### Security Module
280
295
 
281
296
  Define declarative security policies with a fluent, global-style API:
@@ -370,6 +385,29 @@ console.log(documentHandle.mime_type) // "application/pdf"
370
385
  console.log(documentHandle.size) // File size in bytes
371
386
  ```
372
387
 
388
+ The way back out takes the same handle and answers with the file's bytes:
389
+
390
+ ```typescript
391
+ import { read, readRange, size } from '@simpleplatform/sdk/storage'
392
+
393
+ const bytes: Uint8Array = await read(documentHandle, request.context)
394
+
395
+ const length = await size(documentHandle, request.context) // without reading any of it
396
+ const head = await readRange(documentHandle, 0, 1024, request.context) // the first kilobyte
397
+ ```
398
+
399
+ The bytes cross from the host as they are, with no JSON and no base64. `read`
400
+ asks for the size first and reads the file in ranges of at most
401
+ `MAX_RANGE_BYTES` (16 MiB): a file that fits one range arrives as one array, and
402
+ a larger one is read into a single buffer allocated once at exactly its size.
403
+ `readRange` asks for the size first too, so a range that runs past the end of
404
+ the file answers with exactly the bytes up to the end, and one that starts at or
405
+ past the end is refused before any of it is read.
406
+
407
+ Reading is for server actions; a browser action is refused. It needs a runtime
408
+ plugin that provides `__host.callBytes`, and an older plugin is refused with a
409
+ message saying so rather than handed bytes it would try to read as JSON.
410
+
373
411
  ### Type Definitions
374
412
 
375
413
  The TypeScript SDK is **fully typed** with comprehensive TypeScript definitions. Leverage IDE autocompletion and compile-time type checking:
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.
package/dist/host.d.ts CHANGED
@@ -7,6 +7,21 @@ import type { Context, SimpleResponse } from './types';
7
7
  * parsed response as a value.
8
8
  */
9
9
  export declare function execute<T = any>(actionName: string, params: any, context: Context): SimpleResponse<T>;
10
+ /**
11
+ * Calls an action whose reply is a run of bytes, such as a range of a stored
12
+ * file, and returns them.
13
+ *
14
+ * The runtime hands the bytes over as a `Uint8Array` that owns them: no JSON,
15
+ * no base64, and no address. A host that refused answers with its ordinary
16
+ * envelope instead, returned here as a failed response, so a caller checks
17
+ * `ok` exactly as it does for `execute`. Its message names the call — `<action>
18
+ * failed: <the host's reason>` — worded as the Rust and Go SDKs word it.
19
+ *
20
+ * A runtime plugin released before this call existed does not provide it, and
21
+ * is refused here, before anything is sent, rather than handed a reply it
22
+ * would try to read as JSON.
23
+ */
24
+ export declare function executeBytes(actionName: string, params: any, context: Context): SimpleResponse<Uint8Array>;
10
25
  /**
11
26
  * Calls an action on the host without waiting for it to answer.
12
27
  */
package/dist/host.js CHANGED
@@ -11,6 +11,7 @@ function assertRuntimeAbi() {
11
11
  throw new Error(ABI_MISMATCH_MESSAGE);
12
12
  }
13
13
  }
14
+ const BYTES_ABI_MISSING_MESSAGE = 'Reading a reply as bytes needs __host.callBytes, which this runtime plugin does not provide. Install a runtime plugin released with the matching SDK.';
14
15
  /**
15
16
  * Calls an action on the host and returns its response.
16
17
  *
@@ -23,6 +24,40 @@ export function execute(actionName, params, context) {
23
24
  void context;
24
25
  return __host.call(actionName, params ?? null);
25
26
  }
27
+ /**
28
+ * Calls an action whose reply is a run of bytes, such as a range of a stored
29
+ * file, and returns them.
30
+ *
31
+ * The runtime hands the bytes over as a `Uint8Array` that owns them: no JSON,
32
+ * no base64, and no address. A host that refused answers with its ordinary
33
+ * envelope instead, returned here as a failed response, so a caller checks
34
+ * `ok` exactly as it does for `execute`. Its message names the call — `<action>
35
+ * failed: <the host's reason>` — worded as the Rust and Go SDKs word it.
36
+ *
37
+ * A runtime plugin released before this call existed does not provide it, and
38
+ * is refused here, before anything is sent, rather than handed a reply it
39
+ * would try to read as JSON.
40
+ */
41
+ export function executeBytes(actionName, params, context) {
42
+ assertRuntimeAbi();
43
+ void context;
44
+ if (typeof __host.callBytes !== 'function') {
45
+ throw new TypeError(BYTES_ABI_MISSING_MESSAGE);
46
+ }
47
+ const reply = __host.callBytes(actionName, params ?? null);
48
+ if (reply instanceof Uint8Array) {
49
+ return { data: reply, ok: true };
50
+ }
51
+ // The runtime answers with bytes or with the host's refusal, and nothing
52
+ // else. A refusal that says it succeeded is still a refusal: no bytes came.
53
+ const refusal = reply;
54
+ if (refusal?.ok !== false)
55
+ return { error: { message: `${actionName} was refused and gave no reason.` }, ok: false };
56
+ const reason = refusal.error?.message;
57
+ if (typeof reason !== 'string' || reason.trim() === '')
58
+ return { error: { message: `${actionName} failed: The host refused the call and gave no reason.` }, ok: false };
59
+ return { error: { message: `${actionName} failed: ${reason}` }, ok: false };
60
+ }
26
61
  /**
27
62
  * Calls an action on the host without waiting for it to answer.
28
63
  */
package/dist/http.d.ts CHANGED
@@ -4,6 +4,13 @@ import type { Context } from './types';
4
4
  */
5
5
  export interface HttpRequest {
6
6
  body?: any;
7
+ /**
8
+ * The browser's credentials mode for the request: whether it carries the
9
+ * page's cookies and HTTP authentication. A request that names none sends
10
+ * none, and the browser host then omits credentials. The server host has no
11
+ * browser credentials and ignores it.
12
+ */
13
+ credentials?: 'include' | 'omit' | 'same-origin';
7
14
  headers?: Record<string, string>;
8
15
  method?: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
9
16
  url: string;
package/dist/http.js CHANGED
@@ -18,6 +18,7 @@ export async function fetch(request, context) {
18
18
  }
19
19
  const hostRequest = {
20
20
  body: request.body ? JSON.stringify(request.body) : undefined,
21
+ credentials: request.credentials,
21
22
  headers: request.headers,
22
23
  method: request.method ?? 'GET',
23
24
  url: request.url,
package/dist/index.js CHANGED
@@ -88,12 +88,11 @@ async function handle(handler) {
88
88
  // All other contexts (WASM Loader, Elixir Backend) are handled below.
89
89
  let context;
90
90
  try {
91
- const inputText = readInputFromHost();
92
- if (!inputText) {
91
+ const simpleReq = readRequestFromHost();
92
+ if (simpleReq === undefined) {
93
93
  returnError('no input payload provided by the host environment', undefined);
94
94
  return;
95
95
  }
96
- const simpleReq = JSON.parse(inputText);
97
96
  context = simpleReq.context;
98
97
  // Context 1: This is the initial WASM loader running in the browser.
99
98
  // Its only job is to start the script worker.
@@ -146,6 +145,39 @@ function readInputFromHost() {
146
145
  // text because that is what its callers parse.
147
146
  return JSON.stringify(host.getContext());
148
147
  }
148
+ /**
149
+ * Reads the request the host sent, or `undefined` when it sent none.
150
+ *
151
+ * Inside a WASM module the request is handed over as a value the runtime
152
+ * parsed from the host's own JSON, so it is read as it arrived. It used to be
153
+ * serialised and parsed again, which cost the whole payload twice over — the
154
+ * data is one string holding all of it, so ten megabytes were escaped into a
155
+ * new string and unescaped into another — and gave back what it started with,
156
+ * apart from the two values JSON cannot write: a negative zero came back as
157
+ * zero, and an infinite number as null. An action now sees what the host sent,
158
+ * those two included.
159
+ *
160
+ * Nothing here writes to the value. It belongs to the host, which stays free to
161
+ * hand over a frozen envelope, to answer `data` from a getter, or to read its
162
+ * own envelope back — as an action does when it calls `getContext` itself.
163
+ *
164
+ * The script worker in the browser reads a different thing. There the host
165
+ * leaves the request on the global as a JavaScript object of its own making,
166
+ * which reached the worker as a structured clone and can hold what JSON has no
167
+ * form for, such as a `Date` or an `undefined`. The round trip is what turns
168
+ * that into the request an action expects, so that path keeps it until a sweep
169
+ * of its own decides otherwise.
170
+ */
171
+ function readRequestFromHost() {
172
+ if (globalThis.__SIMPLE_INITIAL_PAYLOAD__) {
173
+ const inputText = readInputFromHost();
174
+ return inputText ? JSON.parse(inputText) : undefined;
175
+ }
176
+ // `undefined` is what the runtime answers when the host sent no input at all,
177
+ // and it is the one thing a parsed JSON document can never be, so no request
178
+ // is mistaken for a missing one.
179
+ return host.getContext();
180
+ }
149
181
  function returnError(message, context) {
150
182
  const response = { data: null, errors: [message], ok: false };
151
183
  // Provide a minimal, safe context if the original is not available.
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,12 @@
1
1
  import type { Context, DocumentHandle, ExternalFileSource, StorageTarget } from './types';
2
+ /**
3
+ * The longest range the host answers one read with.
4
+ *
5
+ * `read` and `readRange` cover anything longer in ranges of this size, into one
6
+ * buffer, so it bounds what the host holds for one call rather than what an
7
+ * action can read.
8
+ */
9
+ export declare const MAX_RANGE_BYTES: number;
2
10
  /**
3
11
  * Uploads an in-memory binary buffer as a document to the platform's storage system.
4
12
  *
@@ -58,3 +66,71 @@ export declare function uploadBuffer(buffer: ArrayBuffer | Uint8Array, filename:
58
66
  * ```
59
67
  */
60
68
  export declare function uploadExternal(source: ExternalFileSource, target: StorageTarget, context: Context): Promise<DocumentHandle>;
69
+ /**
70
+ * Returns how many bytes a stored file holds, from the store's own record of it
71
+ * rather than the handle's `size`.
72
+ *
73
+ * @param handle The document handle, exactly as a `:document` field holds it.
74
+ * @param context The execution context for the request.
75
+ * @returns A promise that resolves with the file's size in bytes.
76
+ * @throws If the handle does not name a stored file, or the host refuses.
77
+ *
78
+ * @example
79
+ * ```typescript
80
+ * const bytes = await size(record.attachment, request.context)
81
+ * ```
82
+ */
83
+ export declare function size(handle: DocumentHandle, context: Context): Promise<number>;
84
+ /**
85
+ * Returns the whole of a stored file, as its bytes.
86
+ *
87
+ * The size is asked for first, and the file arrives in ranges of at most
88
+ * `MAX_RANGE_BYTES`, each handed over by the host as it is — no JSON and no
89
+ * base64. A file that fits one range arrives as one array holding exactly its
90
+ * bytes. A larger one is read into a single buffer allocated once at exactly
91
+ * the file's size, and is refused before any range is read when this action
92
+ * has no memory for it. A range answered short — a file that changed while it
93
+ * was read — is refused rather than handed over incomplete.
94
+ *
95
+ * Reading is for server actions; a browser action is refused.
96
+ *
97
+ * @param handle The document handle, exactly as a `:document` field holds it.
98
+ * @param context The execution context for the request.
99
+ * @returns A promise that resolves with the file's bytes.
100
+ * @throws If the handle does not name a stored file, the host refuses, or the
101
+ * file changed while it was read.
102
+ *
103
+ * @example
104
+ * ```typescript
105
+ * const bytes = await read(record.attachment, request.context)
106
+ * const text = new TextDecoder().decode(bytes)
107
+ * ```
108
+ */
109
+ export declare function read(handle: DocumentHandle, context: Context): Promise<Uint8Array>;
110
+ /**
111
+ * Returns up to `length` bytes of a stored file, starting `offset` bytes in.
112
+ *
113
+ * The size is asked for first, so the range is held at what the file has past
114
+ * `offset`: one that runs past the end answers with exactly the bytes up to
115
+ * the end, and one that starts at or past the end is refused before any range
116
+ * is read. What is left is read as `read` reads a whole file — in ranges of at
117
+ * most `MAX_RANGE_BYTES`, into one buffer, each range answered in full or
118
+ * refused.
119
+ *
120
+ * Asking for the size first is also what makes a host that cannot read stored
121
+ * files refuse in its own words, before any range is asked of it.
122
+ *
123
+ * @param handle The document handle, exactly as a `:document` field holds it.
124
+ * @param offset How many bytes into the file the range starts, zero or more.
125
+ * @param length How many bytes to read, one or more.
126
+ * @param context The execution context for the request.
127
+ * @returns A promise that resolves with the range's bytes.
128
+ * @throws If the handle or the range is not valid, the range starts at or past
129
+ * the end of the file, or the host refuses.
130
+ *
131
+ * @example
132
+ * ```typescript
133
+ * const head = await readRange(record.attachment, 0, 1024, request.context)
134
+ * ```
135
+ */
136
+ export declare function readRange(handle: DocumentHandle, offset: number, length: number, context: Context): Promise<Uint8Array>;
package/dist/storage.js CHANGED
@@ -1,4 +1,16 @@
1
- import { execute as hostExecute } from './host';
1
+ import { execute as hostExecute, executeBytes as hostExecuteBytes } from './host';
2
+ /** The host action that answers a stored file's size. */
3
+ const STAT = 'action:storage/stat';
4
+ /** The host action that answers one range of a stored file as its bytes. */
5
+ const READ = 'action:storage/read';
6
+ /**
7
+ * The longest range the host answers one read with.
8
+ *
9
+ * `read` and `readRange` cover anything longer in ranges of this size, into one
10
+ * buffer, so it bounds what the host holds for one call rather than what an
11
+ * action can read.
12
+ */
13
+ export const MAX_RANGE_BYTES = 16 * 1024 * 1024;
2
14
  /**
3
15
  * Uploads an in-memory binary buffer as a document to the platform's storage system.
4
16
  *
@@ -115,3 +127,142 @@ export async function uploadExternal(source, target, context) {
115
127
  }
116
128
  return response.data;
117
129
  }
130
+ /**
131
+ * Returns how many bytes a stored file holds, from the store's own record of it
132
+ * rather than the handle's `size`.
133
+ *
134
+ * @param handle The document handle, exactly as a `:document` field holds it.
135
+ * @param context The execution context for the request.
136
+ * @returns A promise that resolves with the file's size in bytes.
137
+ * @throws If the handle does not name a stored file, or the host refuses.
138
+ *
139
+ * @example
140
+ * ```typescript
141
+ * const bytes = await size(record.attachment, request.context)
142
+ * ```
143
+ */
144
+ export async function size(handle, context) {
145
+ checkHandle(handle);
146
+ const response = await hostExecute(STAT, { handle }, context);
147
+ if (!response.ok)
148
+ throw refused(STAT, response.error?.message);
149
+ const answered = response.data?.size;
150
+ if (typeof answered !== 'number' || !Number.isSafeInteger(answered) || answered < 0)
151
+ throw new Error(`${STAT} answered without a size: ${JSON.stringify(response.data ?? null)}`);
152
+ return answered;
153
+ }
154
+ /**
155
+ * Returns the whole of a stored file, as its bytes.
156
+ *
157
+ * The size is asked for first, and the file arrives in ranges of at most
158
+ * `MAX_RANGE_BYTES`, each handed over by the host as it is — no JSON and no
159
+ * base64. A file that fits one range arrives as one array holding exactly its
160
+ * bytes. A larger one is read into a single buffer allocated once at exactly
161
+ * the file's size, and is refused before any range is read when this action
162
+ * has no memory for it. A range answered short — a file that changed while it
163
+ * was read — is refused rather than handed over incomplete.
164
+ *
165
+ * Reading is for server actions; a browser action is refused.
166
+ *
167
+ * @param handle The document handle, exactly as a `:document` field holds it.
168
+ * @param context The execution context for the request.
169
+ * @returns A promise that resolves with the file's bytes.
170
+ * @throws If the handle does not name a stored file, the host refuses, or the
171
+ * file changed while it was read.
172
+ *
173
+ * @example
174
+ * ```typescript
175
+ * const bytes = await read(record.attachment, request.context)
176
+ * const text = new TextDecoder().decode(bytes)
177
+ * ```
178
+ */
179
+ export async function read(handle, context) {
180
+ const total = await size(handle, context);
181
+ return total === 0 ? new Uint8Array(0) : readSpan(handle, 0, total, context);
182
+ }
183
+ /**
184
+ * Returns up to `length` bytes of a stored file, starting `offset` bytes in.
185
+ *
186
+ * The size is asked for first, so the range is held at what the file has past
187
+ * `offset`: one that runs past the end answers with exactly the bytes up to
188
+ * the end, and one that starts at or past the end is refused before any range
189
+ * is read. What is left is read as `read` reads a whole file — in ranges of at
190
+ * most `MAX_RANGE_BYTES`, into one buffer, each range answered in full or
191
+ * refused.
192
+ *
193
+ * Asking for the size first is also what makes a host that cannot read stored
194
+ * files refuse in its own words, before any range is asked of it.
195
+ *
196
+ * @param handle The document handle, exactly as a `:document` field holds it.
197
+ * @param offset How many bytes into the file the range starts, zero or more.
198
+ * @param length How many bytes to read, one or more.
199
+ * @param context The execution context for the request.
200
+ * @returns A promise that resolves with the range's bytes.
201
+ * @throws If the handle or the range is not valid, the range starts at or past
202
+ * the end of the file, or the host refuses.
203
+ *
204
+ * @example
205
+ * ```typescript
206
+ * const head = await readRange(record.attachment, 0, 1024, request.context)
207
+ * ```
208
+ */
209
+ export async function readRange(handle, offset, length, context) {
210
+ checkHandle(handle);
211
+ if (!Number.isSafeInteger(offset) || offset < 0)
212
+ throw new Error('A range needs an offset that is a whole number of bytes, zero or more.');
213
+ if (!Number.isSafeInteger(length) || length < 1)
214
+ throw new Error('A range needs at least one byte. Pass a length of one or more, or ask size() how large the file is.');
215
+ const total = await size(handle, context);
216
+ if (offset >= total)
217
+ throw new Error(`The range starts at byte ${offset}, at or past the end of the file, which is ${total} bytes. Ask size() how large the file is, and start the range before its end.`);
218
+ return readSpan(handle, offset, Math.min(length, total - offset), context);
219
+ }
220
+ /**
221
+ * Exactly `length` bytes from `offset`, which the file's size says are there.
222
+ *
223
+ * One range is returned as the host handed it over. More are read into one
224
+ * buffer, allocated once at exactly `length` before any of them is asked for.
225
+ */
226
+ async function readSpan(handle, offset, length, context) {
227
+ if (length <= MAX_RANGE_BYTES)
228
+ return readExactly(handle, offset, length, context);
229
+ const bytes = allocate(length);
230
+ for (let done = 0; done < length; done += MAX_RANGE_BYTES) {
231
+ const part = await readExactly(handle, offset + done, Math.min(MAX_RANGE_BYTES, length - done), context);
232
+ bytes.set(part, done);
233
+ }
234
+ return bytes;
235
+ }
236
+ /** One range, which must be answered in full. */
237
+ async function readExactly(handle, offset, length, context) {
238
+ const response = await hostExecuteBytes(READ, { handle, length, offset }, context);
239
+ if (!response.ok)
240
+ throw new Error(response.error?.message ?? `${READ} failed: The host refused the call and gave no reason.`);
241
+ const part = response.data;
242
+ if (part.length !== length)
243
+ throw new Error(`The file answered ${part.length} bytes for the ${length} at offset ${offset}, so it is not the size it was when the read began. Read it again.`);
244
+ return part;
245
+ }
246
+ /** The error for a call the host refused in its envelope, naming the call. */
247
+ function refused(actionName, message) {
248
+ if (message === undefined || message.trim() === '')
249
+ return new Error(`${actionName} failed: The host refused the call and gave no reason.`);
250
+ return new Error(`${actionName} failed: ${message}`);
251
+ }
252
+ /** A buffer of exactly `length` bytes, or a refusal saying there is no room. */
253
+ function allocate(length) {
254
+ try {
255
+ return new Uint8Array(length);
256
+ }
257
+ catch {
258
+ throw new Error(`Reading ${length} bytes needs more memory than this action has. Raise the action's mem_limit, or read the file in parts with readRange.`);
259
+ }
260
+ }
261
+ /** Whether a handle carries what the host locates a stored file by. */
262
+ function checkHandle(handle) {
263
+ for (const member of ['storage_path', 'filename', 'file_hash']) {
264
+ const value = handle?.[member];
265
+ if (typeof value !== 'string' || value.trim() === '')
266
+ throw new Error(`A document handle needs ${member}. Pass the handle exactly as the :document field holds it.`);
267
+ }
268
+ }
@@ -56,6 +56,18 @@ export function execute(actionName, params, context) {
56
56
  })
57
57
  }
58
58
 
59
+ /**
60
+ * A reply that is a run of bytes reaches only a server action: the browser
61
+ * runtime has no way to hand one over. Refused here, before anything is sent.
62
+ * @returns {Promise<import('./types').SimpleResponse<Uint8Array>>} A promise that rejects
63
+ */
64
+ export function executeBytes(actionName) {
65
+ return Promise.reject(new Error(
66
+ `${actionName} answers with bytes, which only a server action can receive. `
67
+ + 'Set the action\'s execution environment to server.',
68
+ ))
69
+ }
70
+
59
71
  /**
60
72
  * Worker-compatible fire-and-forget implementation.
61
73
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simpleplatform/sdk",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Simple Platform Typescript SDK",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://docs.simple.dev",
@@ -45,6 +45,10 @@
45
45
  "types": "./dist/security.d.ts",
46
46
  "default": "./dist/security.js"
47
47
  },
48
+ "./storage": {
49
+ "types": "./dist/storage.d.ts",
50
+ "default": "./dist/storage.js"
51
+ },
48
52
  "./host": {
49
53
  "types": "./dist/host.d.ts",
50
54
  "default": "./dist/host.js"