@sparkvault/sdk-mobile 7.0.0 → 7.0.1

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/config.ts CHANGED
@@ -31,6 +31,11 @@ export interface SparkVaultMobileConfig {
31
31
  userAgent?: string;
32
32
  timeoutMs?: number;
33
33
  fileTransferTimeoutMs?: number;
34
+ /**
35
+ * Timeout of a tus create POST and DELETE. A HEAD and the receipt (the
36
+ * PATCH at the final offset) wait HEAD_AND_RECEIPT_TIMEOUT_MS instead:
37
+ * Forge answers either only after its own call to Core.
38
+ */
34
39
  tusPostTimeoutMs?: number;
35
40
  /** Stall watchdog per tus chunk PATCH: aborts after this long with NO upload progress (not an absolute deadline). */
36
41
  tusChunkTimeoutMs?: number;
@@ -53,6 +58,21 @@ export interface SparkVaultMobileConfig {
53
58
  * upload, and every upload when this is unset, streams over XHR.
54
59
  */
55
60
  backgroundUploader?: MobileBackgroundUploader;
61
+ /**
62
+ * Whether the device has a network path right now (an app wires it to its
63
+ * OS network signal). Read when an upload request got no answer: while it
64
+ * answers false the upload waits for `onOnline` instead of spending its
65
+ * retries, then asks Forge where the session stands. Only a definite
66
+ * "offline" should answer false; a path that may come up is online. Given
67
+ * together with `onOnline`, or not at all.
68
+ */
69
+ isOnline?: () => boolean;
70
+ /**
71
+ * Subscribe to the network coming back; returns the unsubscribe. The pair
72
+ * of `isOnline`. A wait it ends is otherwise ended only by the upload's
73
+ * `abortSignal`.
74
+ */
75
+ onOnline?: (listener: () => void) => () => void;
56
76
  logger?: Partial<SparkVaultLogger>;
57
77
  /**
58
78
  * Host allowlist for the backend-issued Forge transfer URLs the SDK sends an
@@ -62,6 +82,12 @@ export interface SparkVaultMobileConfig {
62
82
  allowedDownloadHostPatterns?: RegExp[];
63
83
  }
64
84
 
85
+ /** The device's network signal, as the upload's offline wait reads it (`isOnline` with `onOnline`). */
86
+ export interface MobileConnectivity {
87
+ isOnline(): boolean;
88
+ onOnline(listener: () => void): () => void;
89
+ }
90
+
65
91
  export interface ResolvedMobileConfig {
66
92
  tokenStorage: TokenStorageAdapter;
67
93
  apiBaseUrl: string;
@@ -78,10 +104,23 @@ export interface ResolvedMobileConfig {
78
104
  fileDownloader?: MobileFileDownloader;
79
105
  backgroundTransfer?: MobileBackgroundTransfer;
80
106
  backgroundUploader?: MobileBackgroundUploader;
107
+ connectivity?: MobileConnectivity;
81
108
  logger: SparkVaultLogger;
82
109
  allowedDownloadHostPatterns: RegExp[];
83
110
  }
84
111
 
112
+ /** The `isOnline` / `onOnline` pair as one signal, or none: one without the other cannot wait for the network. */
113
+ function resolveConnectivity(config: SparkVaultMobileConfig): MobileConnectivity | undefined {
114
+ const { isOnline, onOnline } = config;
115
+ if (isOnline === undefined && onOnline === undefined) return undefined;
116
+ if (typeof isOnline !== 'function' || typeof onOnline !== 'function') {
117
+ throw new SparkVaultValidationError('isOnline and onOnline are given together, as functions', {
118
+ field: typeof isOnline === 'function' ? 'onOnline' : 'isOnline',
119
+ });
120
+ }
121
+ return { isOnline, onOnline };
122
+ }
123
+
85
124
  function normalizeBaseUrl(url: string, field: string): string {
86
125
  if (!url || typeof url !== 'string') {
87
126
  throw new SparkVaultValidationError(`${field} must be a non-empty string`, { field });
@@ -157,6 +196,7 @@ export function resolveMobileConfig(config: SparkVaultMobileConfig): ResolvedMob
157
196
  fileDownloader: config.fileDownloader,
158
197
  backgroundTransfer: config.backgroundTransfer,
159
198
  backgroundUploader: config.backgroundUploader,
199
+ connectivity: resolveConnectivity(config),
160
200
  logger: { ...noopLogger, ...config.logger },
161
201
  allowedDownloadHostPatterns: config.allowedDownloadHostPatterns ?? [...SPARKVAULT_OWNED_HOST_PATTERNS],
162
202
  };
package/src/errors.ts CHANGED
@@ -181,28 +181,62 @@ export class TusUploadError extends SparkVaultMobileError {
181
181
  readonly cause: Error | null;
182
182
  readonly filename: string | null;
183
183
  readonly fileSize: number | null;
184
- readonly phase: 'create' | 'upload' | 'network' | 'timeout' | 'cancelled' | 'stalled' | 'unknown';
184
+ /**
185
+ * Where the upload stopped: `finalize` is after the transfer, when every
186
+ * byte was on Forge but the ingot was not confirmed published.
187
+ */
188
+ readonly phase: 'create' | 'upload' | 'finalize' | 'network' | 'timeout' | 'cancelled' | 'stalled' | 'unknown';
185
189
  /**
186
190
  * True when Forge no longer has the session (it answered a status
187
- * `isSessionGone` recognises: expired or terminated), or the session
188
- * cannot carry this file. The persisted session is worthless, so the
189
- * caller starts a fresh upload instead of retrying it.
191
+ * `isSessionGone` recognises, or the expiry it named has passed), or the
192
+ * session cannot carry this file. The persisted session is worthless, so
193
+ * the caller starts a fresh upload instead of retrying it.
190
194
  */
191
195
  readonly sessionLost: boolean;
196
+ /**
197
+ * The `Retry-After` of the answer that ended the upload, in ms: the
198
+ * earliest a next attempt can expect another answer. Null when that answer
199
+ * named none, or no answer arrived.
200
+ */
201
+ readonly retryAfterMs: number | null;
202
+ /**
203
+ * True when every byte is on Forge and the session is intact but the ingot
204
+ * was not confirmed published: the vault did not read it `active` within
205
+ * the activation poll budget, or Forge was still saving the file at the
206
+ * upload's idle limit (code `upload_received_publishing`; it finishes
207
+ * without this device). The caller retries later by resuming the session
208
+ * it persisted, which asks Forge for the receipt again and sends no bytes.
209
+ */
210
+ readonly retryLater: boolean;
211
+ /**
212
+ * The code Core ended this upload with, as Forge named it. With code
213
+ * `upload_refused` a final refusal (`UPLOAD_POOL_FULL`: the account's
214
+ * storage pool cannot take it; `UPLOAD_SUPERSEDED`: a later upload replaced
215
+ * it), whose message is Core's own sentence and which no new upload of the
216
+ * same file remedies; with `sessionLost` any other code (`NOT_FOUND`: the
217
+ * file the upload was for is gone; `UPLOAD_PROTOCOL_RETIRED`; a 412's),
218
+ * which a fresh upload starts over from. Null when the answer named none.
219
+ */
220
+ readonly coreCode: string | null;
192
221
 
193
222
  constructor(
194
223
  message: string,
195
224
  options: {
196
225
  cause?: Error | null;
197
226
  httpStatus?: number | null;
227
+ /** The error's code when it names an outcome of its own; otherwise the HTTP status, or `tus_upload_error`. */
228
+ code?: string;
198
229
  filename?: string | null;
199
230
  fileSize?: number | null;
200
231
  phase?: TusUploadError['phase'];
201
232
  sessionLost?: boolean;
233
+ retryAfterMs?: number | null;
234
+ retryLater?: boolean;
235
+ coreCode?: string | null;
202
236
  } = {}
203
237
  ) {
204
238
  super(message, {
205
- code: options.httpStatus ?? 'tus_upload_error',
239
+ code: options.code ?? options.httpStatus ?? 'tus_upload_error',
206
240
  statusCode: options.httpStatus ?? undefined,
207
241
  });
208
242
  this.name = 'TusUploadError';
@@ -211,6 +245,9 @@ export class TusUploadError extends SparkVaultMobileError {
211
245
  this.fileSize = options.fileSize ?? null;
212
246
  this.phase = options.phase ?? 'unknown';
213
247
  this.sessionLost = options.sessionLost ?? false;
248
+ this.retryAfterMs = options.retryAfterMs ?? null;
249
+ this.retryLater = options.retryLater ?? false;
250
+ this.coreCode = options.coreCode ?? null;
214
251
  Object.setPrototypeOf(this, TusUploadError.prototype);
215
252
  }
216
253
 
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  createSparkVaultMobileClient,
4
4
  } from './client.js';
5
5
  export type {
6
+ MobileConnectivity,
6
7
  SparkVaultMobileConfig,
7
8
  ResolvedMobileConfig,
8
9
  } from './config.js';
@@ -92,6 +93,7 @@ export {
92
93
  MobileUsersClient,
93
94
  } from './users.js';
94
95
  export {
96
+ isSessionExpired,
95
97
  isSessionGone,
96
98
  MobileTusUploader,
97
99
  parseForgeUrl,
package/src/ingots.ts CHANGED
@@ -7,9 +7,10 @@ import {
7
7
  isRetryableDownloadError,
8
8
  SparkVaultMobileError,
9
9
  SparkVaultValidationError,
10
+ TusUploadError,
10
11
  } from './errors.js';
11
12
  import type { MobileHttpClient } from './http.js';
12
- import { allowsHost, MobileTusUploader } from './tus.js';
13
+ import { allowsHost, MobileTusUploader, type TusSessionProbe } from './tus.js';
13
14
  import type {
14
15
  DebugLogger,
15
16
  HashCheckResponse,
@@ -97,6 +98,12 @@ export interface UploadFromUriOptions extends UploadOptions {
97
98
  * attempt or a relaunch instead of re-sending bytes Forge already holds.
98
99
  */
99
100
  onSessionCreated?: (session: IngotUploadSession) => void;
101
+ /**
102
+ * Invoked whenever the session's expiry changes (see
103
+ * `TusUploadFromUriOptions.onSessionExpiry`): a caller that persists the
104
+ * session keeps its `expiresAt` current with it.
105
+ */
106
+ onSessionExpiry?: (expiresAt: number | null) => void;
100
107
  /** See `UploadTransport`; default `foreground`. */
101
108
  transport?: UploadTransport;
102
109
  /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
@@ -127,8 +134,14 @@ export interface ResumeUploadOptions {
127
134
  fileUri: string;
128
135
  fileSize: number;
129
136
  name: string;
130
- /** The persisted tus session handed to `onSessionCreated`. */
131
- session: { uploadUrl: string; istk: string };
137
+ /**
138
+ * The persisted tus session handed to `onSessionCreated`, with the expiry
139
+ * the caller keeps for it (`onSessionExpiry`): a session past it is
140
+ * refused as `sessionLost` before any request.
141
+ */
142
+ session: { uploadUrl: string; istk: string; expiresAt?: number | null };
143
+ /** See `UploadFromUriOptions.onSessionExpiry`. */
144
+ onSessionExpiry?: (expiresAt: number | null) => void;
132
145
  /** See `UploadTransport`; default `foreground`. */
133
146
  transport?: UploadTransport;
134
147
  /** Network policy for the OS transfer engine; see `UploadTransferPolicy`. Default: both allowed. */
@@ -343,6 +356,7 @@ export class MobileIngotsClient {
343
356
  filename: name,
344
357
  forgeUrl: init.forgeUrl,
345
358
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId: init.ingotId, created }),
359
+ onSessionExpiry: options.onSessionExpiry,
346
360
  transport: options.transport,
347
361
  transferPolicy: options.transferPolicy,
348
362
  onProgress: options.onProgress,
@@ -351,7 +365,11 @@ export class MobileIngotsClient {
351
365
  debug: options.debug,
352
366
  });
353
367
 
354
- const ingot = await this.verifyIngotActive(options.vaultId, init.ingotId, options.vat, options.debug);
368
+ const ingot = await this.verifyIngotActive(
369
+ { vaultId: options.vaultId, ingotId: init.ingotId, vat: options.vat },
370
+ { filename: name, fileSize: options.fileSize },
371
+ options.debug
372
+ );
355
373
 
356
374
  return {
357
375
  ingot_id: init.ingotId,
@@ -373,10 +391,10 @@ export class MobileIngotsClient {
373
391
  * sent, then the ingot is verified active exactly as a fresh upload is.
374
392
  *
375
393
  * A `TusUploadError` with `sessionLost` means Forge no longer has the
376
- * session (expired or terminated); the caller drops it and starts over
377
- * with `uploadFromUri`. A session `uploadUrl` that is not an upload session
378
- * on an allowed transfer host is refused (SparkVaultValidationError) before
379
- * the ISTK or a byte is sent anywhere.
394
+ * session (its expiry passed, or it was terminated); the caller drops it
395
+ * and starts over with `uploadFromUri`. A session `uploadUrl` that is not
396
+ * an upload session on an allowed transfer host is refused
397
+ * (SparkVaultValidationError) before the ISTK or a byte is sent anywhere.
380
398
  */
381
399
  async resumeUpload(options: ResumeUploadOptions): Promise<UploadResult> {
382
400
  validateVaultId(options.vaultId);
@@ -390,6 +408,7 @@ export class MobileIngotsClient {
390
408
  fileSize: options.fileSize,
391
409
  filename: options.name,
392
410
  resume: options.session,
411
+ onSessionExpiry: options.onSessionExpiry,
393
412
  transport: options.transport,
394
413
  transferPolicy: options.transferPolicy,
395
414
  onProgress: options.onProgress,
@@ -398,7 +417,11 @@ export class MobileIngotsClient {
398
417
  debug: options.debug,
399
418
  });
400
419
 
401
- const ingot = await this.verifyIngotActive(options.vaultId, options.ingotId, options.vat, options.debug);
420
+ const ingot = await this.verifyIngotActive(
421
+ { vaultId: options.vaultId, ingotId: options.ingotId, vat: options.vat },
422
+ { filename: options.name, fileSize: options.fileSize },
423
+ options.debug
424
+ );
402
425
 
403
426
  return {
404
427
  ingot_id: options.ingotId,
@@ -418,9 +441,7 @@ export class MobileIngotsClient {
418
441
  * An `uploadUrl` that is not an upload session on an allowed transfer host
419
442
  * is refused (SparkVaultValidationError) before the ISTK is sent anywhere.
420
443
  */
421
- probeUploadSession(
422
- session: { uploadUrl: string; istk: string }
423
- ): Promise<{ offset: number; length: number; chunkSize: number } | null> {
444
+ probeUploadSession(session: { uploadUrl: string; istk: string }): Promise<TusSessionProbe | null> {
424
445
  return this.tus.probeSession(session);
425
446
  }
426
447
 
@@ -512,6 +533,7 @@ export class MobileIngotsClient {
512
533
  forgeUrl: response.data.forge_url,
513
534
  // A replace never mints an ingot: the row is the caller's existing one.
514
535
  onSessionCreated: session => options.onSessionCreated?.({ ...session, ingotId, created: false }),
536
+ onSessionExpiry: options.onSessionExpiry,
515
537
  transport: options.transport,
516
538
  transferPolicy: options.transferPolicy,
517
539
  onProgress,
@@ -520,7 +542,11 @@ export class MobileIngotsClient {
520
542
  debug,
521
543
  });
522
544
 
523
- const ingot = await this.verifyIngotActive(vaultId, response.data.ingot_id, vat, debug);
545
+ const ingot = await this.verifyIngotActive(
546
+ { vaultId, ingotId: response.data.ingot_id, vat },
547
+ { filename: name, fileSize },
548
+ debug
549
+ );
524
550
 
525
551
  return {
526
552
  ingot_id: response.data.ingot_id,
@@ -866,22 +892,24 @@ export class MobileIngotsClient {
866
892
  }
867
893
 
868
894
  /**
869
- * Wait for a just-uploaded ingot to finish server-side finalization.
895
+ * Read a just-uploaded ingot until the server reports it `active`.
870
896
  *
871
- * The bytes are already fully uploaded and the ingot record exists before
872
- * this runs, so this NEVER re-creates or re-uploads anything - it only polls
873
- * status. A still-`uploading` ingot after the poll budget
874
- * is returned as success, not thrown: the transport completed and the ingot
875
- * exists, so treating it as a failure would make the caller re-run the whole
876
- * upload and mint a DUPLICATE ingot (re-streaming the entire file and
877
- * double-billing storage). Finalization completes asynchronously server-side.
897
+ * The upload resolved on Forge's receipt (a 2xx to the PATCH at the final
898
+ * offset, which Forge answers only once the ingot is published), so a read
899
+ * that is not `active` yet is a read lagging that publication, which the
900
+ * poll waits out. It never re-creates or re-uploads anything. A row still
901
+ * not `active` past the poll budget is never reported as a success: it
902
+ * throws a `TusUploadError` whose `retryLater` is set (every byte is
903
+ * received, publication is unconfirmed), so the caller resumes the session
904
+ * it persisted later, which asks Forge for the receipt again and sends no
905
+ * bytes, rather than recording a backup the vault may not hold.
878
906
  */
879
907
  private async verifyIngotActive(
880
- vaultId: string,
881
- ingotId: string,
882
- vat: string,
908
+ ingotRef: { vaultId: string; ingotId: string; vat: string },
909
+ upload: { filename: string; fileSize: number },
883
910
  debug?: DebugLogger
884
911
  ): Promise<Ingot> {
912
+ const { vaultId, ingotId, vat } = ingotRef;
885
913
  const deadline = Date.now() + MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS;
886
914
  let delay = MobileIngotsClient.INGOT_STATUS_POLL_INITIAL_MS;
887
915
  let ingot = await this.get(vaultId, ingotId, vat);
@@ -891,9 +919,12 @@ export class MobileIngotsClient {
891
919
  if (remaining <= 0) {
892
920
  debug?.log(
893
921
  `Ingot ${ingotId} still '${ingot.status}' after ${MobileIngotsClient.INGOT_STATUS_POLL_BUDGET_MS}ms; ` +
894
- 'upload transport is complete, finalizing asynchronously'
922
+ 'the upload is received and will be confirmed on a later attempt'
923
+ );
924
+ throw new TusUploadError(
925
+ `Upload received; the vault has not confirmed ingot ${ingotId} published yet (status ${ingot.status ?? 'unknown'})`,
926
+ { ...upload, phase: 'finalize', retryLater: true }
895
927
  );
896
- return ingot;
897
928
  }
898
929
 
899
930
  await new Promise(resolve => setTimeout(resolve, Math.min(delay, remaining)));