@mountra/mountra-sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,711 @@
1
+ // src/upload.ts
2
+ var JUMBO_CHUNK_THRESHOLD_BYTES = 128 * 1024 * 1024;
3
+ var MIN_S3_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
4
+ var DEFAULT_S3_MULTIPART_PART_SIZE_BYTES = 16 * 1024 * 1024;
5
+ var MAX_S3_MULTIPART_PARTS = 1e4;
6
+ var MountraApiError = class extends Error {
7
+ status;
8
+ code;
9
+ body;
10
+ constructor(message, input) {
11
+ super(message);
12
+ this.name = "MountraApiError";
13
+ this.status = input.status;
14
+ this.code = input.code;
15
+ this.body = input.body;
16
+ }
17
+ };
18
+ var memoryCheckpointRecords = /* @__PURE__ */ new Map();
19
+ function parseJumboCheckpoint(value) {
20
+ if (!isRecord(value) || value.version !== 1 || typeof value.storageKey !== "string") {
21
+ return null;
22
+ }
23
+ if (typeof value.chunkIndex !== "number" || typeof value.chunkSize !== "number" || typeof value.chunkSha256 !== "string" || typeof value.partSize !== "number" || typeof value.uploadId !== "string" || !Array.isArray(value.parts)) {
24
+ return null;
25
+ }
26
+ const parts = [];
27
+ for (const part of value.parts) {
28
+ if (!isRecord(part)) {
29
+ return null;
30
+ }
31
+ if (typeof part.partNumber !== "number" || typeof part.etag !== "string" || typeof part.size !== "number" || typeof part.sha256 !== "string") {
32
+ return null;
33
+ }
34
+ parts.push({
35
+ partNumber: part.partNumber,
36
+ etag: part.etag,
37
+ size: part.size,
38
+ sha256: part.sha256
39
+ });
40
+ }
41
+ return {
42
+ version: 1,
43
+ storageKey: value.storageKey,
44
+ chunkIndex: value.chunkIndex,
45
+ chunkSize: value.chunkSize,
46
+ chunkSha256: value.chunkSha256,
47
+ partSize: value.partSize,
48
+ uploadId: value.uploadId,
49
+ completed: value.completed === true,
50
+ completing: value.completing === true,
51
+ parts
52
+ };
53
+ }
54
+ var InMemoryJumboChunkCheckpointStore = class {
55
+ records = /* @__PURE__ */ new Map();
56
+ async load(key) {
57
+ return this.records.get(key) ?? null;
58
+ }
59
+ async save(key, checkpoint) {
60
+ this.records.set(key, {
61
+ ...checkpoint,
62
+ parts: checkpoint.parts.map((part) => ({ ...part }))
63
+ });
64
+ }
65
+ async remove(key) {
66
+ this.records.delete(key);
67
+ }
68
+ };
69
+ var LocalStorageJumboChunkCheckpointStore = class {
70
+ prefix;
71
+ constructor(prefix = "mountra-sdk:jumbo:") {
72
+ this.prefix = prefix;
73
+ }
74
+ async load(key) {
75
+ const storageKey = this.storageKey(key);
76
+ const raw = this.getStorage()?.getItem(storageKey) ?? memoryCheckpointRecords.get(storageKey);
77
+ if (!raw) {
78
+ return null;
79
+ }
80
+ try {
81
+ return parseJumboCheckpoint(JSON.parse(raw));
82
+ } catch {
83
+ await this.remove(key);
84
+ return null;
85
+ }
86
+ }
87
+ async save(key, checkpoint) {
88
+ const storageKey = this.storageKey(key);
89
+ const serialized = JSON.stringify(checkpoint);
90
+ const storage = this.getStorage();
91
+ if (storage) {
92
+ storage.setItem(storageKey, serialized);
93
+ } else {
94
+ memoryCheckpointRecords.set(storageKey, serialized);
95
+ }
96
+ }
97
+ async remove(key) {
98
+ const storageKey = this.storageKey(key);
99
+ this.getStorage()?.removeItem(storageKey);
100
+ memoryCheckpointRecords.delete(storageKey);
101
+ }
102
+ storageKey(key) {
103
+ return `${this.prefix}${key}`;
104
+ }
105
+ getStorage() {
106
+ try {
107
+ return globalThis.localStorage ?? null;
108
+ } catch {
109
+ return null;
110
+ }
111
+ }
112
+ };
113
+ var S3MultipartUploader = class {
114
+ bucket;
115
+ client;
116
+ constructor(input) {
117
+ const bucket = input.bucket.trim();
118
+ if (!bucket) {
119
+ throw new TypeError("S3 bucket is required");
120
+ }
121
+ this.bucket = bucket;
122
+ this.client = input.client;
123
+ }
124
+ createMultipartUpload(input) {
125
+ return this.client.createMultipartUpload({
126
+ bucket: this.bucket,
127
+ key: input.storageKey,
128
+ contentType: input.contentType,
129
+ signal: input.signal
130
+ });
131
+ }
132
+ uploadPart(input) {
133
+ return this.client.uploadPart({
134
+ bucket: this.bucket,
135
+ key: input.storageKey,
136
+ uploadId: input.uploadId,
137
+ partNumber: input.partNumber,
138
+ body: input.body,
139
+ signal: input.signal
140
+ });
141
+ }
142
+ completeMultipartUpload(input) {
143
+ return this.client.completeMultipartUpload({
144
+ bucket: this.bucket,
145
+ key: input.storageKey,
146
+ uploadId: input.uploadId,
147
+ parts: input.parts,
148
+ signal: input.signal
149
+ });
150
+ }
151
+ abortMultipartUpload(input) {
152
+ if (!this.client.abortMultipartUpload) {
153
+ return Promise.resolve();
154
+ }
155
+ return this.client.abortMultipartUpload({
156
+ bucket: this.bucket,
157
+ key: input.storageKey,
158
+ uploadId: input.uploadId,
159
+ signal: input.signal
160
+ });
161
+ }
162
+ };
163
+ function assertPositiveInteger(value, name) {
164
+ if (!Number.isSafeInteger(value) || value <= 0) {
165
+ throw new RangeError(`${name} must be a positive safe integer`);
166
+ }
167
+ }
168
+ function assertMultipartPartSize(value) {
169
+ assertPositiveInteger(value, "jumboPartSize");
170
+ if (value < MIN_S3_MULTIPART_PART_SIZE_BYTES) {
171
+ throw new RangeError(
172
+ `jumboPartSize must be at least ${MIN_S3_MULTIPART_PART_SIZE_BYTES} bytes for S3 Multipart uploads`
173
+ );
174
+ }
175
+ }
176
+ function normalizeBaseUrl(value) {
177
+ const trimmed = value.trim().replace(/\/+$/, "");
178
+ if (!trimmed) {
179
+ throw new TypeError("baseUrl is required");
180
+ }
181
+ return trimmed;
182
+ }
183
+ function normalizeApiPrefix(value) {
184
+ const trimmed = value.trim();
185
+ if (!trimmed || trimmed === "/") {
186
+ return "";
187
+ }
188
+ return `/${trimmed.replace(/^\/+|\/+$/g, "")}`;
189
+ }
190
+ function getWorkspaceBody(clientSelector, requestSelector) {
191
+ const workspaceCuid = requestSelector.workspaceCuid ?? clientSelector.workspaceCuid;
192
+ const workspaceId = requestSelector.workspaceId ?? clientSelector.workspaceId;
193
+ if (workspaceCuid === void 0 && workspaceId === void 0) {
194
+ throw new TypeError("workspaceCuid or workspaceId is required");
195
+ }
196
+ return {
197
+ ...workspaceCuid !== void 0 ? { workspace_cuid: workspaceCuid } : {},
198
+ ...workspaceId !== void 0 ? { workspace_id: workspaceId } : {}
199
+ };
200
+ }
201
+ function throwIfAborted(signal) {
202
+ if (!signal?.aborted) {
203
+ return;
204
+ }
205
+ throw new DOMException("The upload was aborted", "AbortError");
206
+ }
207
+ function isRecord(value) {
208
+ return typeof value === "object" && value !== null;
209
+ }
210
+ function parseApiErrorBody(body) {
211
+ if (!isRecord(body)) {
212
+ return {};
213
+ }
214
+ return {
215
+ message: typeof body.error === "string" ? body.error : void 0,
216
+ code: typeof body.code === "string" ? body.code : void 0
217
+ };
218
+ }
219
+ function ensurePrepareResponse(value) {
220
+ if (!isRecord(value) || typeof value.upload_id !== "string") {
221
+ throw new TypeError("Mountra prepare response is invalid");
222
+ }
223
+ if (!Array.isArray(value.upload_targets)) {
224
+ throw new TypeError("Mountra prepare response has no upload targets");
225
+ }
226
+ return value;
227
+ }
228
+ function ensureFinishResponse(value) {
229
+ if (!isRecord(value) || typeof value.upload_id !== "string") {
230
+ throw new TypeError("Mountra finish response is invalid");
231
+ }
232
+ return value;
233
+ }
234
+ function ensureUploadStatusResponse(value) {
235
+ if (!isRecord(value) || typeof value.upload_id !== "string") {
236
+ throw new TypeError("Mountra upload status response is invalid");
237
+ }
238
+ if (!Array.isArray(value.chunks)) {
239
+ throw new TypeError("Mountra upload status response has no chunk states");
240
+ }
241
+ return value;
242
+ }
243
+ function splitFileIntoChunks(file, chunkCount = 1) {
244
+ assertPositiveInteger(chunkCount, "chunkCount");
245
+ if (file.size === 0) {
246
+ return [{ index: 0, offset: 0, size: 0, blob: file.slice(0, 0) }];
247
+ }
248
+ if (chunkCount > file.size) {
249
+ throw new RangeError("chunkCount cannot be greater than the file size in bytes");
250
+ }
251
+ const chunkSize = Math.ceil(file.size / chunkCount);
252
+ const chunks = [];
253
+ for (let index = 0; index < chunkCount; index += 1) {
254
+ const offset = index * chunkSize;
255
+ const end = Math.min(offset + chunkSize, file.size);
256
+ chunks.push({
257
+ index,
258
+ offset,
259
+ size: end - offset,
260
+ blob: file.slice(offset, end)
261
+ });
262
+ }
263
+ return chunks;
264
+ }
265
+ function isJumboChunk(size) {
266
+ return size > JUMBO_CHUNK_THRESHOLD_BYTES;
267
+ }
268
+ async function sha256Hex(blob) {
269
+ const subtle = globalThis.crypto?.subtle;
270
+ if (!subtle) {
271
+ throw new Error("Web Crypto SHA-256 is not available in this environment");
272
+ }
273
+ const digest = await subtle.digest("SHA-256", await blob.arrayBuffer());
274
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
275
+ }
276
+ var MountraClient = class {
277
+ baseUrl;
278
+ apiPrefix;
279
+ clientSelector;
280
+ token;
281
+ headers;
282
+ fetchImpl;
283
+ jumboChunkUploader;
284
+ jumboChunkCheckpointStore;
285
+ jumboPartSize;
286
+ constructor(options) {
287
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
288
+ this.apiPrefix = normalizeApiPrefix(options.apiPrefix ?? "/api");
289
+ this.clientSelector = {
290
+ workspaceCuid: options.workspaceCuid,
291
+ workspaceId: options.workspaceId
292
+ };
293
+ this.token = options.token;
294
+ this.headers = options.headers;
295
+ this.jumboChunkUploader = options.jumboChunkUploader;
296
+ this.jumboChunkCheckpointStore = options.jumboChunkCheckpointStore ?? new LocalStorageJumboChunkCheckpointStore();
297
+ this.jumboPartSize = options.jumboPartSize ?? DEFAULT_S3_MULTIPART_PART_SIZE_BYTES;
298
+ assertMultipartPartSize(this.jumboPartSize);
299
+ const fetchImpl = options.fetch ?? globalThis.fetch?.bind(globalThis);
300
+ if (!fetchImpl) {
301
+ throw new Error("fetch is not available; provide fetch in MountraClientOptions");
302
+ }
303
+ this.fetchImpl = fetchImpl;
304
+ }
305
+ async prepareUpload(file, options) {
306
+ const chunks = splitFileIntoChunks(file, options.chunkCount ?? 1);
307
+ const chunkHashes = await Promise.all(chunks.map((chunk) => sha256Hex(chunk.blob)));
308
+ const contentHash = await sha256Hex(file);
309
+ const chunkMethod = chunks.length === 1 ? "none" : "fix";
310
+ const contentType = options.contentType ?? ("type" in file ? file.type : void 0);
311
+ throwIfAborted(options.signal);
312
+ const response = ensurePrepareResponse(
313
+ await this.requestJson("/upload/prepare", {
314
+ ...getWorkspaceBody(this.clientSelector, options),
315
+ path: options.path,
316
+ size: file.size,
317
+ content_hash: contentHash,
318
+ chunks: chunks.map((chunk, index) => ({
319
+ chunk_index: chunk.index,
320
+ size: chunk.size,
321
+ sha256: chunkHashes[index]
322
+ })),
323
+ chunk_method: chunkMethod,
324
+ ...contentType ? { content_type: contentType } : {},
325
+ ...options.nameConflictStrategy ? { name_conflict_strategy: options.nameConflictStrategy } : {}
326
+ }, options.signal)
327
+ );
328
+ const context = {
329
+ uploadId: response.upload_id,
330
+ path: response.path,
331
+ fileSize: file.size,
332
+ contentHash,
333
+ chunkMethod: response.chunk_method,
334
+ chunks: chunks.map((chunk, index) => ({
335
+ index: chunk.index,
336
+ offset: chunk.offset,
337
+ size: chunk.size,
338
+ sha256: chunkHashes[index]
339
+ })),
340
+ ...contentType ? { contentType } : {}
341
+ };
342
+ await options.onSessionPrepared?.(context);
343
+ return { response, chunks, chunkHashes, contentHash, context };
344
+ }
345
+ async uploadFile(file, options) {
346
+ const prepared = await this.prepareUpload(file, options);
347
+ return this.uploadPreparedChunks(file, prepared.context, prepared.chunks, prepared.response.upload_targets, options);
348
+ }
349
+ /** Generic upload entry point. It selects PUT or S3 Multipart per chunk. */
350
+ async upload(file, options) {
351
+ return this.uploadFile(file, options);
352
+ }
353
+ async getUploadStatus(uploadId, selector = {}, signal) {
354
+ if (!uploadId.trim()) {
355
+ throw new TypeError("uploadId is required");
356
+ }
357
+ return ensureUploadStatusResponse(
358
+ await this.requestJson("/upload/status", {
359
+ ...getWorkspaceBody(this.clientSelector, selector),
360
+ upload_id: uploadId
361
+ }, signal)
362
+ );
363
+ }
364
+ /**
365
+ * Resume a previously prepared session. The caller must restore the same
366
+ * file/blob separately; this context only describes how that file was cut.
367
+ */
368
+ async resumeUpload(file, context, options = {}) {
369
+ this.assertUploadContext(context, file);
370
+ const actualChunkHashes = await Promise.all(
371
+ context.chunks.map((chunk) => sha256Hex(file.slice(chunk.offset, chunk.offset + chunk.size)))
372
+ );
373
+ if (actualChunkHashes.some(
374
+ (hash, index) => hash !== context.chunks[index]?.sha256
375
+ ) || await sha256Hex(file) !== context.contentHash) {
376
+ throw new Error("The restored file does not match the upload context hashes");
377
+ }
378
+ const chunks = context.chunks.map((chunk) => ({
379
+ index: chunk.index,
380
+ offset: chunk.offset,
381
+ size: chunk.size,
382
+ blob: file.slice(chunk.offset, chunk.offset + chunk.size)
383
+ }));
384
+ const status = await this.getUploadStatus(context.uploadId, options, options.signal);
385
+ if (status.upload_id !== context.uploadId || status.size !== context.fileSize) {
386
+ throw new Error("Mountra upload status does not match the saved upload context");
387
+ }
388
+ if (status.chunks.length !== context.chunks.length) {
389
+ throw new Error("Mountra upload status has a different chunk layout");
390
+ }
391
+ for (const [index, chunk] of status.chunks.entries()) {
392
+ const savedChunk = context.chunks[index];
393
+ if (chunk.chunk_index !== savedChunk.index || chunk.offset !== savedChunk.offset || chunk.size !== savedChunk.size || chunk.sha256 !== savedChunk.sha256) {
394
+ throw new Error("Mountra upload status has a different chunk layout");
395
+ }
396
+ }
397
+ return this.uploadPreparedChunks(file, context, chunks, status.chunks, options);
398
+ }
399
+ async uploadPreparedChunks(file, context, chunks, targets, options) {
400
+ const orderedTargets = [...targets].sort((left, right) => left.chunk_index - right.chunk_index);
401
+ if (orderedTargets.length !== chunks.length) {
402
+ throw new Error(
403
+ `Mountra returned ${orderedTargets.length} upload targets for ${chunks.length} chunks`
404
+ );
405
+ }
406
+ let uploadedBytes = 0;
407
+ let uploadedChunks = 0;
408
+ const jumboCheckpointKeys = /* @__PURE__ */ new Set();
409
+ for (const [position, target] of orderedTargets.entries()) {
410
+ const chunk = chunks[target.chunk_index];
411
+ if (!chunk || target.chunk_index !== position) {
412
+ throw new Error("Mountra upload targets must contain contiguous chunk indexes");
413
+ }
414
+ throwIfAborted(options.signal);
415
+ if (isJumboChunk(chunk.size)) {
416
+ const checkpointKey = this.buildJumboCheckpointKey(context, target, options);
417
+ jumboCheckpointKeys.add(checkpointKey);
418
+ if (target.upload_required) {
419
+ await this.uploadJumboChunk(
420
+ chunk,
421
+ target,
422
+ context,
423
+ checkpointKey,
424
+ options,
425
+ uploadedBytes,
426
+ uploadedChunks,
427
+ orderedTargets.length
428
+ );
429
+ }
430
+ } else if (target.upload_required) {
431
+ if (!target.put_url) {
432
+ throw new Error(`Mountra upload target ${target.chunk_index} has no signed URL`);
433
+ }
434
+ const response = await this.fetchImpl(target.put_url, {
435
+ method: target.put_method ?? "PUT",
436
+ headers: target.put_headers ?? {},
437
+ body: chunk.blob,
438
+ ...options.signal ? { signal: options.signal } : {}
439
+ });
440
+ if (!response.ok) {
441
+ throw new MountraApiError(
442
+ `Signed upload for chunk ${target.chunk_index} failed with HTTP ${response.status}`,
443
+ { status: response.status }
444
+ );
445
+ }
446
+ }
447
+ uploadedBytes += chunk.size;
448
+ uploadedChunks += 1;
449
+ options.onProgress?.({
450
+ phase: "uploading",
451
+ uploadedBytes,
452
+ totalBytes: file.size,
453
+ uploadedChunks,
454
+ totalChunks: orderedTargets.length
455
+ });
456
+ }
457
+ throwIfAborted(options.signal);
458
+ options.onProgress?.({
459
+ phase: "finishing",
460
+ uploadedBytes: file.size,
461
+ totalBytes: file.size,
462
+ uploadedChunks: orderedTargets.length,
463
+ totalChunks: orderedTargets.length
464
+ });
465
+ const finished = ensureFinishResponse(
466
+ await this.requestJson(
467
+ "/upload/finish",
468
+ {
469
+ ...getWorkspaceBody(this.clientSelector, options),
470
+ upload_id: context.uploadId
471
+ },
472
+ options.signal
473
+ )
474
+ );
475
+ if (finished.finish_status !== "completed") {
476
+ throw new Error(`Mountra upload did not finish successfully: ${finished.finish_status}`);
477
+ }
478
+ await Promise.all(
479
+ [...jumboCheckpointKeys].map(
480
+ (checkpointKey) => this.jumboChunkCheckpointStore.remove(checkpointKey)
481
+ )
482
+ );
483
+ return finished;
484
+ }
485
+ buildJumboCheckpointKey(context, target, selector) {
486
+ const workspaceCuid = selector.workspaceCuid ?? this.clientSelector.workspaceCuid ?? "";
487
+ const workspaceId = selector.workspaceId ?? this.clientSelector.workspaceId ?? "";
488
+ return encodeURIComponent(
489
+ [
490
+ this.baseUrl,
491
+ workspaceCuid,
492
+ workspaceId,
493
+ target.storage_key,
494
+ target.chunk_index,
495
+ context.chunks[target.chunk_index]?.sha256 ?? ""
496
+ ].join("|")
497
+ );
498
+ }
499
+ async uploadJumboChunk(chunk, target, context, checkpointKey, options, baseUploadedBytes, uploadedChunks, totalChunks) {
500
+ const uploader = this.jumboChunkUploader;
501
+ if (!uploader) {
502
+ throw new Error(
503
+ `Chunk ${target.chunk_index} is larger than 128 MiB; configure jumboChunkUploader to use S3 Multipart uploads`
504
+ );
505
+ }
506
+ const totalParts = Math.ceil(chunk.size / this.jumboPartSize);
507
+ if (totalParts > MAX_S3_MULTIPART_PARTS) {
508
+ throw new RangeError(
509
+ `Jumbo chunk requires ${totalParts} parts, exceeding S3's ${MAX_S3_MULTIPART_PARTS}-part limit`
510
+ );
511
+ }
512
+ let checkpoint = await this.jumboChunkCheckpointStore.load(checkpointKey);
513
+ if (checkpoint && !this.isCompatibleJumboCheckpoint(checkpoint, chunk, target, context)) {
514
+ await this.jumboChunkCheckpointStore.remove(checkpointKey);
515
+ checkpoint = null;
516
+ }
517
+ if (!checkpoint) {
518
+ const created = await uploader.createMultipartUpload({
519
+ storageKey: target.storage_key,
520
+ contentType: context.contentType,
521
+ signal: options.signal
522
+ });
523
+ if (!created.uploadId.trim()) {
524
+ throw new Error(`S3 Multipart create returned no upload id for chunk ${target.chunk_index}`);
525
+ }
526
+ checkpoint = {
527
+ version: 1,
528
+ storageKey: target.storage_key,
529
+ chunkIndex: target.chunk_index,
530
+ chunkSize: chunk.size,
531
+ chunkSha256: context.chunks[target.chunk_index]?.sha256 ?? "",
532
+ partSize: this.jumboPartSize,
533
+ uploadId: created.uploadId,
534
+ completed: false,
535
+ completing: false,
536
+ parts: []
537
+ };
538
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
539
+ }
540
+ if (checkpoint.completed) {
541
+ return;
542
+ }
543
+ if (checkpoint.completing) {
544
+ const status = await this.getUploadStatus(context.uploadId, options, options.signal);
545
+ const statusChunk = status.chunks.find(
546
+ (candidate) => candidate.chunk_index === target.chunk_index
547
+ );
548
+ if (statusChunk?.status === "uploaded") {
549
+ checkpoint.completed = true;
550
+ checkpoint.completing = false;
551
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
552
+ return;
553
+ }
554
+ checkpoint.completing = false;
555
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
556
+ }
557
+ const parts = new Map(checkpoint.parts.map((part) => [part.partNumber, part]));
558
+ let uploadedBytes = 0;
559
+ let uploadedParts = 0;
560
+ for (let partNumber = 1; partNumber <= totalParts; partNumber += 1) {
561
+ throwIfAborted(options.signal);
562
+ const start = (partNumber - 1) * this.jumboPartSize;
563
+ const partBlob = chunk.blob.slice(start, Math.min(start + this.jumboPartSize, chunk.size));
564
+ const partHash = await sha256Hex(partBlob);
565
+ const savedPart = parts.get(partNumber);
566
+ const part = savedPart && savedPart.size === partBlob.size && savedPart.sha256 === partHash && savedPart.etag ? savedPart : await this.uploadJumboPart(
567
+ uploader,
568
+ target.storage_key,
569
+ checkpoint.uploadId,
570
+ partNumber,
571
+ partBlob,
572
+ partHash,
573
+ options.signal
574
+ );
575
+ parts.set(partNumber, part);
576
+ checkpoint.parts = [...parts.values()].sort(
577
+ (left, right) => left.partNumber - right.partNumber
578
+ );
579
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
580
+ uploadedBytes += partBlob.size;
581
+ uploadedParts += 1;
582
+ options.onProgress?.({
583
+ phase: "uploading",
584
+ uploadedBytes: baseUploadedBytes + uploadedBytes,
585
+ totalBytes: context.fileSize,
586
+ uploadedChunks,
587
+ totalChunks,
588
+ uploadedParts,
589
+ totalParts
590
+ });
591
+ }
592
+ checkpoint.completing = true;
593
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
594
+ try {
595
+ await uploader.completeMultipartUpload({
596
+ storageKey: target.storage_key,
597
+ uploadId: checkpoint.uploadId,
598
+ parts: checkpoint.parts.map(({ partNumber, etag }) => ({ partNumber, etag })),
599
+ signal: options.signal
600
+ });
601
+ } catch (error) {
602
+ checkpoint.completing = false;
603
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
604
+ throw error;
605
+ }
606
+ checkpoint.completed = true;
607
+ checkpoint.completing = false;
608
+ await this.jumboChunkCheckpointStore.save(checkpointKey, checkpoint);
609
+ }
610
+ async uploadJumboPart(uploader, storageKey, uploadId, partNumber, body, sha256, signal) {
611
+ const result = await uploader.uploadPart({
612
+ storageKey,
613
+ uploadId,
614
+ partNumber,
615
+ body,
616
+ signal
617
+ });
618
+ if (!result.etag.trim()) {
619
+ throw new Error(`S3 Multipart upload returned no ETag for part ${partNumber}`);
620
+ }
621
+ return {
622
+ partNumber,
623
+ etag: result.etag,
624
+ size: body.size,
625
+ sha256
626
+ };
627
+ }
628
+ isCompatibleJumboCheckpoint(checkpoint, chunk, target, context) {
629
+ return checkpoint.version === 1 && checkpoint.storageKey === target.storage_key && checkpoint.chunkIndex === target.chunk_index && checkpoint.chunkSize === chunk.size && checkpoint.chunkSha256 === (context.chunks[target.chunk_index]?.sha256 ?? "") && checkpoint.partSize === this.jumboPartSize && checkpoint.uploadId.trim().length > 0;
630
+ }
631
+ assertUploadContext(context, file) {
632
+ if (!context.uploadId.trim()) {
633
+ throw new TypeError("upload context uploadId is required");
634
+ }
635
+ if (context.fileSize !== file.size) {
636
+ throw new Error("The restored file does not match the upload context size");
637
+ }
638
+ if (!Array.isArray(context.chunks) || context.chunks.length === 0) {
639
+ throw new TypeError("upload context must contain chunks");
640
+ }
641
+ let expectedOffset = 0;
642
+ for (const [index, chunk] of context.chunks.entries()) {
643
+ if (chunk.index !== index || chunk.offset !== expectedOffset || chunk.offset < 0 || chunk.size < 0 || chunk.offset + chunk.size > file.size || typeof chunk.sha256 !== "string") {
644
+ throw new TypeError("upload context contains an invalid chunk layout");
645
+ }
646
+ expectedOffset += chunk.size;
647
+ }
648
+ }
649
+ async finishUpload(uploadId, selector = {}) {
650
+ if (!uploadId.trim()) {
651
+ throw new TypeError("uploadId is required");
652
+ }
653
+ return ensureFinishResponse(
654
+ await this.requestJson("/upload/finish", {
655
+ ...getWorkspaceBody(this.clientSelector, selector),
656
+ upload_id: uploadId
657
+ })
658
+ );
659
+ }
660
+ endpoint(path) {
661
+ return `${this.baseUrl}${this.apiPrefix}${path}`;
662
+ }
663
+ async requestJson(path, body, signal) {
664
+ const headers = new Headers(this.headers);
665
+ headers.set("Content-Type", "application/json");
666
+ const token = typeof this.token === "function" ? await this.token() : this.token;
667
+ if (token) {
668
+ headers.set("Authorization", `Bearer ${token}`);
669
+ }
670
+ const response = await this.fetchImpl(this.endpoint(path), {
671
+ method: "POST",
672
+ headers,
673
+ body: JSON.stringify(body),
674
+ ...signal ? { signal } : {}
675
+ });
676
+ let responseBody;
677
+ try {
678
+ responseBody = await response.json();
679
+ } catch {
680
+ responseBody = void 0;
681
+ }
682
+ if (!response.ok) {
683
+ const error = parseApiErrorBody(responseBody);
684
+ throw new MountraApiError(
685
+ error.message ?? `Mountra API request failed with HTTP ${response.status}`,
686
+ { status: response.status, code: error.code, body: responseBody }
687
+ );
688
+ }
689
+ return responseBody;
690
+ }
691
+ };
692
+ function createMountraClient(options) {
693
+ return new MountraClient(options);
694
+ }
695
+ export {
696
+ DEFAULT_S3_MULTIPART_PART_SIZE_BYTES,
697
+ InMemoryJumboChunkCheckpointStore,
698
+ JUMBO_CHUNK_THRESHOLD_BYTES,
699
+ LocalStorageJumboChunkCheckpointStore,
700
+ MAX_S3_MULTIPART_PARTS,
701
+ MIN_S3_MULTIPART_PART_SIZE_BYTES,
702
+ MountraApiError,
703
+ MountraClient,
704
+ MountraClient as MountraSDK,
705
+ S3MultipartUploader,
706
+ createMountraClient,
707
+ isJumboChunk,
708
+ sha256Hex,
709
+ splitFileIntoChunks
710
+ };
711
+ //# sourceMappingURL=index.js.map