@sparkvault/sdk-mobile 5.2.2 → 5.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/src/ingots.ts CHANGED
@@ -16,9 +16,13 @@ import type {
16
16
  IngotInvite,
17
17
  IngotSharingConfig,
18
18
  ListIngotsResponse,
19
+ IngotUploadSession,
19
20
  MobileFileDownloader,
21
+ UploadAbortBehavior,
20
22
  UploadProgressCallback,
21
23
  UploadResult,
24
+ UploadTransferPolicy,
25
+ UploadTransport,
22
26
  } from './types.js';
23
27
  import { validateIngotId, validateVaultId } from './validation.js';
24
28
 
@@ -51,6 +55,34 @@ export interface UploadOptions {
51
55
  export interface UploadFromUriOptions extends UploadOptions {
52
56
  fileUri: string;
53
57
  fileSize: number;
58
+ /**
59
+ * Invoked once the API has minted (or, via its same-name upsert, handed
60
+ * back) the ingot and before any Forge session exists. `created: false`
61
+ * means the bytes about to be sent will REPLACE an existing ingot's content
62
+ * once they finalize; a caller that must never overwrite throws here, and
63
+ * the throw propagates as-is with nothing sent and no session to clean up.
64
+ */
65
+ onIngotCreated?: (ingot: { ingotId: string; created: boolean }) => void;
66
+ /**
67
+ * Invoked once the Forge tus session exists and before the first byte is
68
+ * sent, so the caller can persist it and `resumeUpload` after a failed
69
+ * attempt or a relaunch instead of re-sending bytes Forge already holds.
70
+ */
71
+ onSessionCreated?: (session: IngotUploadSession) => void;
72
+ /** See `UploadTransport`; default `foreground`. */
73
+ transport?: UploadTransport;
74
+ /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
75
+ transferPolicy?: UploadTransferPolicy;
76
+ /**
77
+ * What an abort does to the Forge session; see `UploadAbortBehavior`.
78
+ * Default `terminate` (a user cancel: Forge drops the chunks). `keep`
79
+ * (a pause) leaves the session on Forge so the caller can `resumeUpload`
80
+ * the one it was handed via `onSessionCreated` from the server offset;
81
+ * the caller then owns that session and is expected to resume or
82
+ * terminate it. The error is the same either way: the pre-flight
83
+ * `upload_cancelled` or the tus `cancelled`-phase `TusUploadError`.
84
+ */
85
+ abortBehavior?: UploadAbortBehavior;
54
86
  debug?: DebugLogger;
55
87
  }
56
88
 
@@ -58,6 +90,31 @@ export interface ReplaceFromUriOptions extends UploadFromUriOptions {
58
90
  ingotId: string;
59
91
  }
60
92
 
93
+ export interface ResumeUploadOptions {
94
+ vaultId: string;
95
+ vat: string;
96
+ /** The ingot the session was created for (`UploadResult.ingot_id`). */
97
+ ingotId: string;
98
+ fileUri: string;
99
+ fileSize: number;
100
+ name: string;
101
+ contentType: string;
102
+ /** The persisted tus session handed to `onSessionCreated`. */
103
+ session: { uploadUrl: string; istk: string };
104
+ /** See `UploadTransport`; default `foreground`. */
105
+ transport?: UploadTransport;
106
+ /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
107
+ transferPolicy?: UploadTransferPolicy;
108
+ onProgress?: UploadProgressCallback;
109
+ abortSignal?: AbortSignal;
110
+ /**
111
+ * See `UploadFromUriOptions.abortBehavior`. `keep` leaves `session` on
112
+ * Forge across the abort so the same session can be resumed again later.
113
+ */
114
+ abortBehavior?: UploadAbortBehavior;
115
+ debug?: DebugLogger;
116
+ }
117
+
61
118
  export interface DownloadToFileOptions {
62
119
  vaultId: string;
63
120
  ingotId: string;
@@ -109,6 +166,11 @@ export interface CreateUploadResult {
109
166
  forgeUrl: string;
110
167
  name?: string;
111
168
  sizeBytes?: number;
169
+ /**
170
+ * Whether the server minted this ingot (true) or handed back an existing
171
+ * row via its same-name upsert (false). Absent when the server did not say.
172
+ */
173
+ created?: boolean;
112
174
  }
113
175
 
114
176
  export interface CreateDownloadLinkOptions {
@@ -127,6 +189,7 @@ interface IngotUploadInitResponse {
127
189
  forge_url: string;
128
190
  name?: string;
129
191
  size_bytes?: number;
192
+ created?: boolean;
130
193
  }
131
194
 
132
195
  interface IngotAuditLogEntry {
@@ -214,6 +277,12 @@ export class MobileIngotsClient {
214
277
  folderId: options.folderId,
215
278
  contentHmac: options.contentHmac,
216
279
  });
280
+ const created = init.created ?? true;
281
+
282
+ // The ingot row exists but no Forge session does: a caller that refuses
283
+ // to replace an existing ingot (created: false) aborts here, before a
284
+ // byte moves, and its error reaches it untouched.
285
+ options.onIngotCreated?.({ ingotId: init.ingotId, created });
217
286
 
218
287
  await this.tus.uploadFromUri({
219
288
  fileUri: options.fileUri,
@@ -221,21 +290,96 @@ export class MobileIngotsClient {
221
290
  filename: options.name,
222
291
  contentType: options.contentType,
223
292
  forgeUrl: init.forgeUrl,
293
+ onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
294
+ transport: options.transport,
295
+ transferPolicy: options.transferPolicy,
224
296
  onProgress: options.onProgress,
225
297
  abortSignal: options.abortSignal,
298
+ abortBehavior: options.abortBehavior,
226
299
  debug: options.debug,
227
300
  });
228
301
 
229
- await this.verifyIngotActive(options.vaultId, init.ingotId, options.vat, options.debug);
302
+ const ingot = await this.verifyIngotActive(options.vaultId, init.ingotId, options.vat, options.debug);
230
303
 
231
304
  return {
232
305
  ingot_id: init.ingotId,
233
306
  name: init.name ?? options.name,
234
307
  size_bytes: init.sizeBytes ?? options.fileSize,
235
308
  storage_location: 's3',
309
+ // A server that does not report it minted the row (the upsert is the
310
+ // only other outcome, and it always says so).
311
+ created,
312
+ status: ingot.status,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Continue an interrupted upload from the tus session `uploadFromUri`
318
+ * handed to `onSessionCreated`. No ingot is created: the record already
319
+ * exists from the original call, and the session is bound to it — Forge is
320
+ * asked (HEAD) where the transfer stands and only the missing bytes are
321
+ * sent, then the ingot is verified active exactly as a fresh upload is.
322
+ *
323
+ * A `TusUploadError` with `sessionLost` means Forge no longer has the
324
+ * session (expired or terminated); the caller drops it and starts over
325
+ * with `uploadFromUri`.
326
+ */
327
+ async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
328
+ validateVaultId(options.vaultId);
329
+ validateIngotId(options.ingotId);
330
+ if (options.abortSignal?.aborted) {
331
+ throw new SparkVaultMobileError('Upload cancelled', { code: 'upload_cancelled' });
332
+ }
333
+
334
+ await this.tus.uploadFromUri({
335
+ fileUri: options.fileUri,
336
+ fileSize: options.fileSize,
337
+ filename: options.name,
338
+ contentType: options.contentType,
339
+ resume: options.session,
340
+ transport: options.transport,
341
+ transferPolicy: options.transferPolicy,
342
+ onProgress: options.onProgress,
343
+ abortSignal: options.abortSignal,
344
+ abortBehavior: options.abortBehavior,
345
+ debug: options.debug,
346
+ });
347
+
348
+ const ingot = await this.verifyIngotActive(options.vaultId, options.ingotId, options.vat, options.debug);
349
+
350
+ return {
351
+ ingot_id: options.ingotId,
352
+ name: options.name,
353
+ size_bytes: options.fileSize,
354
+ storage_location: 's3',
355
+ created: false,
356
+ status: ingot.status,
236
357
  };
237
358
  }
238
359
 
360
+ /**
361
+ * Where a persisted tus session stands on Forge, or `null` when Forge no
362
+ * longer has it. Lets a caller reconcile sessions on relaunch — a session
363
+ * at `offset === length` only needs its ingot verified, a lost one needs a
364
+ * fresh `uploadFromUri` — without spending an upload slot to find out.
365
+ */
366
+ probeUploadSession(
367
+ session: { uploadUrl: string; istk: string }
368
+ ): Promise<{ offset: number; length: number; chunkSize: number } | null> {
369
+ return this.tus.probeSession(session);
370
+ }
371
+
372
+ /**
373
+ * Release a tus session the caller kept on Forge (`abortBehavior: 'keep'`)
374
+ * and has since abandoned, so Forge drops the chunks it holds and settles
375
+ * the partial transfer's billing instead of waiting for expiry. Resolves
376
+ * once the session is gone, including when Forge no longer had it
377
+ * (404/410); any other failure rejects so the caller can retry later.
378
+ */
379
+ terminateUploadSession(session: { uploadUrl: string; istk: string }): Promise<void> {
380
+ return this.tus.terminateSession(session);
381
+ }
382
+
239
383
  /**
240
384
  * Initialize an ingot record and obtain the Forge resumable-upload endpoint
241
385
  * (with its upload-session token). The canonical first step of every upload;
@@ -273,6 +417,7 @@ export class MobileIngotsClient {
273
417
  forgeUrl: response.data.forge_url,
274
418
  name: response.data.name,
275
419
  sizeBytes: response.data.size_bytes,
420
+ created: typeof response.data.created === 'boolean' ? response.data.created : undefined,
276
421
  };
277
422
  }
278
423
 
@@ -306,18 +451,26 @@ export class MobileIngotsClient {
306
451
  filename: name,
307
452
  contentType,
308
453
  forgeUrl: response.data.forge_url,
454
+ // A replace never mints an ingot: the row is the caller's existing one.
455
+ onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
456
+ transport: options.transport,
457
+ transferPolicy: options.transferPolicy,
309
458
  onProgress,
310
459
  abortSignal,
460
+ abortBehavior: options.abortBehavior,
311
461
  debug,
312
462
  });
313
463
 
314
- await this.verifyIngotActive(vaultId, response.data.ingot_id, vat, debug);
464
+ const ingot = await this.verifyIngotActive(vaultId, response.data.ingot_id, vat, debug);
315
465
 
316
466
  return {
317
467
  ingot_id: response.data.ingot_id,
318
468
  name,
319
469
  size_bytes: fileSize,
320
470
  storage_location: 's3',
471
+ // A replace never mints an ingot: the row is the caller's existing one.
472
+ created: false,
473
+ status: ingot.status,
321
474
  };
322
475
  }
323
476