@protontech/drive-sdk 0.8.0 → 0.9.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.
Files changed (82) hide show
  1. package/dist/errors.d.ts +1 -1
  2. package/dist/errors.js +2 -2
  3. package/dist/errors.js.map +1 -1
  4. package/dist/interface/download.d.ts +14 -0
  5. package/dist/internal/apiService/apiService.js +6 -2
  6. package/dist/internal/apiService/apiService.js.map +1 -1
  7. package/dist/internal/apiService/apiService.test.js +9 -1
  8. package/dist/internal/apiService/apiService.test.js.map +1 -1
  9. package/dist/internal/download/controller.d.ts +3 -0
  10. package/dist/internal/download/controller.js +7 -0
  11. package/dist/internal/download/controller.js.map +1 -1
  12. package/dist/internal/download/cryptoService.js +9 -2
  13. package/dist/internal/download/cryptoService.js.map +1 -1
  14. package/dist/internal/download/fileDownloader.js +9 -3
  15. package/dist/internal/download/fileDownloader.js.map +1 -1
  16. package/dist/internal/download/fileDownloader.test.js +14 -11
  17. package/dist/internal/download/fileDownloader.test.js.map +1 -1
  18. package/dist/internal/download/interface.d.ts +14 -0
  19. package/dist/internal/download/interface.js +16 -0
  20. package/dist/internal/download/interface.js.map +1 -1
  21. package/dist/internal/download/seekableStream.d.ts +1 -2
  22. package/dist/internal/download/seekableStream.js +28 -11
  23. package/dist/internal/download/seekableStream.js.map +1 -1
  24. package/dist/internal/download/seekableStream.test.js +100 -0
  25. package/dist/internal/download/seekableStream.test.js.map +1 -1
  26. package/dist/internal/nodes/apiService.d.ts +1 -0
  27. package/dist/internal/nodes/apiService.js +3 -0
  28. package/dist/internal/nodes/apiService.js.map +1 -1
  29. package/dist/internal/nodes/nodesManagement.d.ts +1 -0
  30. package/dist/internal/nodes/nodesManagement.js +5 -0
  31. package/dist/internal/nodes/nodesManagement.js.map +1 -1
  32. package/dist/internal/photos/index.js +2 -2
  33. package/dist/internal/photos/index.js.map +1 -1
  34. package/dist/internal/upload/apiService.d.ts +1 -0
  35. package/dist/internal/upload/apiService.js +12 -0
  36. package/dist/internal/upload/apiService.js.map +1 -1
  37. package/dist/internal/upload/index.js +4 -4
  38. package/dist/internal/upload/index.js.map +1 -1
  39. package/dist/internal/upload/manager.js +19 -1
  40. package/dist/internal/upload/manager.js.map +1 -1
  41. package/dist/internal/upload/manager.test.js +23 -0
  42. package/dist/internal/upload/manager.test.js.map +1 -1
  43. package/dist/internal/upload/queue.d.ts +18 -3
  44. package/dist/internal/upload/queue.js +27 -8
  45. package/dist/internal/upload/queue.js.map +1 -1
  46. package/dist/internal/upload/queue.test.d.ts +1 -0
  47. package/dist/internal/upload/queue.test.js +103 -0
  48. package/dist/internal/upload/queue.test.js.map +1 -0
  49. package/dist/internal/upload/streamUploader.d.ts +3 -0
  50. package/dist/internal/upload/streamUploader.js +30 -2
  51. package/dist/internal/upload/streamUploader.js.map +1 -1
  52. package/dist/internal/upload/streamUploader.test.js +65 -6
  53. package/dist/internal/upload/streamUploader.test.js.map +1 -1
  54. package/dist/internal/upload/telemetry.js +2 -3
  55. package/dist/internal/upload/telemetry.js.map +1 -1
  56. package/dist/protonDriveClient.js +1 -1
  57. package/dist/protonDriveClient.js.map +1 -1
  58. package/package.json +1 -1
  59. package/src/errors.ts +2 -2
  60. package/src/interface/download.ts +16 -0
  61. package/src/internal/apiService/apiService.test.ts +12 -2
  62. package/src/internal/apiService/apiService.ts +7 -2
  63. package/src/internal/download/controller.ts +9 -0
  64. package/src/internal/download/cryptoService.ts +13 -3
  65. package/src/internal/download/fileDownloader.test.ts +17 -11
  66. package/src/internal/download/fileDownloader.ts +9 -5
  67. package/src/internal/download/interface.ts +15 -0
  68. package/src/internal/download/seekableStream.test.ts +126 -1
  69. package/src/internal/download/seekableStream.ts +34 -13
  70. package/src/internal/nodes/apiService.ts +7 -0
  71. package/src/internal/nodes/nodesManagement.ts +6 -0
  72. package/src/internal/photos/index.ts +2 -2
  73. package/src/internal/upload/apiService.ts +25 -0
  74. package/src/internal/upload/index.ts +4 -4
  75. package/src/internal/upload/manager.test.ts +37 -0
  76. package/src/internal/upload/manager.ts +17 -1
  77. package/src/internal/upload/queue.test.ts +130 -0
  78. package/src/internal/upload/queue.ts +34 -9
  79. package/src/internal/upload/streamUploader.test.ts +80 -7
  80. package/src/internal/upload/streamUploader.ts +34 -2
  81. package/src/internal/upload/telemetry.ts +2 -3
  82. package/src/protonDriveClient.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { SeekableReadableStream, BufferedSeekableStream } from './seekableStream';
1
+ import { SeekableReadableStream, BufferedSeekableStream, UnderlyingSeekableSource } from './seekableStream';
2
2
 
3
3
  describe('SeekableReadableStream', () => {
4
4
  it('should call the seek callback when seek is called', async () => {
@@ -32,6 +32,7 @@ describe('SeekableReadableStream', () => {
32
32
  describe('BufferedSeekableStream', () => {
33
33
  let startWithCloseMock: jest.Mock;
34
34
  let pullMock: jest.Mock;
35
+ let seekableSource: UnderlyingSeekableSource;
35
36
 
36
37
  const data1 = new Uint8Array([1, 2, 3, 4, 5]);
37
38
  const data2 = new Uint8Array([6, 7, 8, 9, 10]);
@@ -53,6 +54,26 @@ describe('BufferedSeekableStream', () => {
53
54
  }
54
55
  readIndex++;
55
56
  });
57
+
58
+ // Simulates a real seekable source where seek repositions the read pointer
59
+ const fileData = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]);
60
+ let readPosition = 0;
61
+ const chunkSize = 5;
62
+
63
+ seekableSource = {
64
+ pull: jest.fn().mockImplementation((controller) => {
65
+ if (readPosition >= fileData.length) {
66
+ controller.close();
67
+ return;
68
+ }
69
+ const chunk = fileData.slice(readPosition, readPosition + chunkSize);
70
+ readPosition += chunk.length;
71
+ controller.enqueue(chunk);
72
+ }),
73
+ seek: jest.fn().mockImplementation((position: number) => {
74
+ readPosition = position;
75
+ }),
76
+ };
56
77
  });
57
78
 
58
79
  it('should throw error if highWaterMark is not 0', () => {
@@ -184,4 +205,108 @@ describe('BufferedSeekableStream', () => {
184
205
  const result4 = await stream.read(2);
185
206
  expect(result4).toEqual({ value: new Uint8Array([10]), done: true });
186
207
  });
208
+
209
+ it('should catch and ignore TypeError from releaseLock during seek', async () => {
210
+ const stream = new BufferedSeekableStream({
211
+ pull: pullMock,
212
+ seek: jest.fn(),
213
+ });
214
+
215
+ await stream.read(2);
216
+
217
+ const reader = (stream as any).reader;
218
+ const originalReleaseLock = reader.releaseLock.bind(reader);
219
+ jest.spyOn(reader, 'releaseLock').mockImplementation(() => {
220
+ originalReleaseLock();
221
+ throw new TypeError('Reader has pending read requests');
222
+ });
223
+
224
+ await expect(stream.seek(0)).resolves.not.toThrow();
225
+ });
226
+
227
+ it('should re-throw non-TypeError errors from releaseLock during seek', async () => {
228
+ const stream = new BufferedSeekableStream({
229
+ pull: pullMock,
230
+ seek: jest.fn(),
231
+ });
232
+
233
+ await stream.read(2);
234
+
235
+ const reader = (stream as any).reader;
236
+ const customError = new Error('Custom error');
237
+ jest.spyOn(reader, 'releaseLock').mockImplementation(() => {
238
+ throw customError;
239
+ });
240
+
241
+ await expect(stream.seek(0)).rejects.toThrow(customError);
242
+ });
243
+
244
+ it('should not call underlying seek when seeking within buffer range', async () => {
245
+ const seekMock = jest.fn();
246
+ const stream = new BufferedSeekableStream({
247
+ pull: pullMock,
248
+ seek: seekMock,
249
+ });
250
+
251
+ await stream.read(2);
252
+ expect(seekMock).not.toHaveBeenCalled();
253
+
254
+ // Seek within buffer range (buffer has bytes for positions 2-4)
255
+ await stream.seek(3);
256
+ expect(seekMock).not.toHaveBeenCalled();
257
+
258
+ // Seek to current position (still within buffer)
259
+ await stream.seek(3);
260
+ expect(seekMock).not.toHaveBeenCalled();
261
+ });
262
+
263
+ it('should not corrupt data when seeking to current position with seekable underlying source', async () => {
264
+ const stream = new BufferedSeekableStream(seekableSource);
265
+
266
+ // Read first 3 bytes [0, 1, 2], buffer will have [0, 1, 2, 3, 4]
267
+ const result1 = await stream.read(3);
268
+ expect(result1.value).toEqual(new Uint8Array([0, 1, 2]));
269
+ expect(seekableSource.seek).not.toHaveBeenCalled();
270
+
271
+ // Seek to position 3 (current position), should use buffer without seeking underlying source
272
+ await stream.seek(3);
273
+ expect(seekableSource.seek).not.toHaveBeenCalled();
274
+
275
+ // Buffer has [3, 4], needs 2 more from underlying source
276
+ // Underlying source stays at position 5, giving [5, 6, 7, 8, 9]
277
+ // Buffer becomes [3, 4, 5, 6, 7, 8, 9]
278
+ const result2 = await stream.read(4);
279
+ expect(result2.value).toEqual(new Uint8Array([3, 4, 5, 6]));
280
+ expect(seekableSource.seek).not.toHaveBeenCalled();
281
+
282
+ // Continue reading to verify stream integrity
283
+ const result3 = await stream.read(3);
284
+ expect(result3.value).toEqual(new Uint8Array([7, 8, 9]));
285
+ });
286
+
287
+ it('should call underlying seek only when seeking outside buffer range', async () => {
288
+ const stream = new BufferedSeekableStream(seekableSource);
289
+
290
+ // Read first 3 bytes [0, 1, 2], buffer will have [0, 1, 2, 3, 4]
291
+ await stream.read(3);
292
+ expect(seekableSource.seek).not.toHaveBeenCalled();
293
+
294
+ // Seek backward (outside buffer range) - should call underlying seek
295
+ await stream.seek(0);
296
+ expect(seekableSource.seek).toHaveBeenCalledWith(0);
297
+ expect(seekableSource.seek).toHaveBeenCalledTimes(1);
298
+
299
+ // Read and verify data is correct after backward seek
300
+ const result1 = await stream.read(3);
301
+ expect(result1.value).toEqual(new Uint8Array([0, 1, 2]));
302
+
303
+ // Seek forward past buffer end - should call underlying seek
304
+ await stream.seek(10);
305
+ expect(seekableSource.seek).toHaveBeenCalledWith(10);
306
+ expect(seekableSource.seek).toHaveBeenCalledTimes(2);
307
+
308
+ // Read and verify data is correct after forward seek
309
+ const result2 = await stream.read(3);
310
+ expect(result2.value).toEqual(new Uint8Array([10, 11, 12]));
311
+ });
187
312
  });
@@ -1,4 +1,4 @@
1
- interface UnderlyingSeekableSource extends UnderlyingDefaultSource<Uint8Array> {
1
+ export interface UnderlyingSeekableSource extends UnderlyingDefaultSource<Uint8Array> {
2
2
  seek: (position: number) => void | Promise<void>;
3
3
  }
4
4
 
@@ -22,7 +22,10 @@ interface UnderlyingSeekableSource extends UnderlyingDefaultSource<Uint8Array> {
22
22
  export class SeekableReadableStream extends ReadableStream<Uint8Array> {
23
23
  private seekCallback: (position: number) => void | Promise<void>;
24
24
 
25
- constructor({ seek, ...underlyingSource }: UnderlyingSeekableSource, queuingStrategy?: QueuingStrategy<Uint8Array>) {
25
+ constructor(
26
+ { seek, ...underlyingSource }: UnderlyingSeekableSource,
27
+ queuingStrategy?: QueuingStrategy<Uint8Array>,
28
+ ) {
26
29
  super(underlyingSource, queuingStrategy);
27
30
  this.seekCallback = seek;
28
31
  }
@@ -160,23 +163,41 @@ export class BufferedSeekableStream extends SeekableReadableStream {
160
163
  async seek(position: number): Promise<void> {
161
164
  const endOfBufferPosition = this.currentPosition + (this.buffer.length - this.bufferPosition);
162
165
 
163
- if (position > endOfBufferPosition) {
164
- this.buffer = new Uint8Array(0);
165
- this.bufferPosition = 0;
166
- } else if (position < this.currentPosition) {
166
+ if (position > endOfBufferPosition || position < this.currentPosition) {
167
167
  this.buffer = new Uint8Array(0);
168
168
  this.bufferPosition = 0;
169
+
170
+ await super.seek(position);
171
+
172
+ if (this.reader) {
173
+ try {
174
+ this.reader.releaseLock();
175
+ } catch (error) {
176
+ // Streams API spec-compliant behavior: releaseLock() only throws TypeError when
177
+ // there are pending read requests. This can occur due to timing differences between
178
+ // when read() promises resolve on the client side vs when the browser's internal
179
+ // stream mechanism fully completes.
180
+ //
181
+ // This manifests more frequently in Firefox than Chrome due to implementation
182
+ // timing differences, but both are following the spec correctly.
183
+ //
184
+ // References:
185
+ // - https://github.com/whatwg/streams/issues/1000
186
+ // - https://developer.mozilla.org/en-US/docs/Web/API/ReadableStreamDefaultReader/releaseLock
187
+ //
188
+ // Safe to ignore since we're acquiring a new reader immediately after.
189
+ if (!(error instanceof TypeError)) {
190
+ throw error;
191
+ }
192
+ }
193
+ }
194
+ this.reader = super.getReader();
195
+ this.streamClosed = false;
169
196
  } else {
197
+ // Position is within buffer range, just update buffer position.
170
198
  this.bufferPosition += position - this.currentPosition;
171
199
  }
172
200
 
173
- await super.seek(position);
174
-
175
- if (this.reader) {
176
- this.reader.releaseLock();
177
- }
178
- this.reader = super.getReader();
179
- this.streamClosed = false;
180
201
  this.currentPosition = position;
181
202
  }
182
203
  }
@@ -57,6 +57,9 @@ type PostCopyNodeRequest = Extract<
57
57
  type PostCopyNodeResponse =
58
58
  drivePaths['/drive/volumes/{volumeID}/links/{linkID}/copy']['post']['responses']['200']['content']['application/json'];
59
59
 
60
+ type EmptyTrashResponse =
61
+ drivePaths['/drive/volumes/{volumeID}/trash']['delete']['responses']['200']['content']['application/json'];
62
+
60
63
  type PostTrashNodesRequest = Extract<
61
64
  drivePaths['/drive/v2/volumes/{volumeID}/trash_multiple']['post']['requestBody'],
62
65
  { content: object }
@@ -436,6 +439,10 @@ export abstract class NodeAPIServiceBase<
436
439
  }
437
440
  }
438
441
 
442
+ async emptyTrash(volumeId: string): Promise<void> {
443
+ await this.apiService.delete<EmptyTrashResponse>(`drive/volumes/${volumeId}/trash`);
444
+ }
445
+
439
446
  async *restoreNodes(nodeUids: string[], signal?: AbortSignal): AsyncGenerator<NodeResult> {
440
447
  for (const { volumeId, batchNodeIds, batchNodeUids } of groupNodeUidsByVolumeAndIteratePerBatch(nodeUids)) {
441
448
  const response = await this.apiService.put<PutRestoreNodesRequest, PutRestoreNodesResponse>(
@@ -127,6 +127,12 @@ export abstract class NodesManagementBase<
127
127
  }
128
128
  }
129
129
 
130
+ async emptyTrash(): Promise<void> {
131
+ const node = await this.nodesAccess.getVolumeRootFolder();
132
+ const { volumeId } = splitNodeUid(node.uid);
133
+ await this.apiService.emptyTrash(volumeId);
134
+ }
135
+
130
136
  async moveNode(nodeUid: string, newParentUid: string): Promise<TDecryptedNode> {
131
137
  const node = await this.nodesAccess.getNode(nodeUid);
132
138
 
@@ -159,10 +159,10 @@ export function initPhotoUploadModule(
159
159
  metadata: PhotoUploadMetadata,
160
160
  signal?: AbortSignal,
161
161
  ): Promise<PhotoFileUploader> {
162
- await queue.waitForCapacity(signal);
162
+ await queue.waitForCapacity(metadata.expectedSize, signal);
163
163
 
164
164
  const onFinish = () => {
165
- queue.releaseCapacity();
165
+ queue.releaseCapacity(metadata.expectedSize);
166
166
  };
167
167
 
168
168
  return new PhotoFileUploader(
@@ -45,6 +45,13 @@ type PostDeleteNodesRequest = Extract<
45
45
  type PostDeleteNodesResponse =
46
46
  drivePaths['/drive/v2/volumes/{volumeID}/delete_multiple']['post']['responses']['200']['content']['application/json'];
47
47
 
48
+ type PostLoadLinksMetadataRequest = Extract<
49
+ drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['requestBody'],
50
+ { content: object }
51
+ >['content']['application/json'];
52
+ type PostLoadLinksMetadataResponse =
53
+ drivePaths['/drive/v2/volumes/{volumeID}/links']['post']['responses']['200']['content']['application/json'];
54
+
48
55
  export class UploadAPIService {
49
56
  constructor(
50
57
  protected apiService: DriveAPIService,
@@ -262,4 +269,22 @@ export class UploadAPIService {
262
269
 
263
270
  await this.apiService.postBlockStream(url, token, formData, onProgress, signal);
264
271
  }
272
+
273
+ async isRevisionUploaded(nodeRevisionUid: string): Promise<boolean> {
274
+ const { volumeId, nodeId, revisionId } = splitNodeRevisionUid(nodeRevisionUid);
275
+ const result = await this.apiService.post<PostLoadLinksMetadataRequest, PostLoadLinksMetadataResponse>(
276
+ `drive/v2/volumes/${volumeId}/links`,
277
+ {
278
+ LinkIDs: [nodeId],
279
+ },
280
+ );
281
+ if (result.Links.length === 0) {
282
+ return false;
283
+ }
284
+ const link = result.Links[0];
285
+ return (
286
+ link.Link.State === 1 && // ACTIVE state
287
+ link.File?.ActiveRevision?.RevisionID === revisionId
288
+ );
289
+ }
265
290
  }
@@ -45,10 +45,10 @@ export function initUploadModule(
45
45
  metadata: UploadMetadata,
46
46
  signal?: AbortSignal,
47
47
  ): Promise<FileUploader> {
48
- await queue.waitForCapacity(signal);
48
+ await queue.waitForCapacity(metadata.expectedSize, signal);
49
49
 
50
50
  const onFinish = () => {
51
- queue.releaseCapacity();
51
+ queue.releaseCapacity(metadata.expectedSize);
52
52
  };
53
53
 
54
54
  return new FileUploader(
@@ -76,10 +76,10 @@ export function initUploadModule(
76
76
  metadata: UploadMetadata,
77
77
  signal?: AbortSignal,
78
78
  ): Promise<FileRevisionUploader> {
79
- await queue.waitForCapacity(signal);
79
+ await queue.waitForCapacity(metadata.expectedSize, signal);
80
80
 
81
81
  const onFinish = () => {
82
- queue.releaseCapacity();
82
+ queue.releaseCapacity(metadata.expectedSize);
83
83
  };
84
84
 
85
85
  return new FileRevisionUploader(
@@ -327,5 +327,42 @@ describe('UploadManager', () => {
327
327
  expect(nodesService.notifyChildCreated).toHaveBeenCalledWith('parentUid');
328
328
  expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
329
329
  });
330
+
331
+ it('should ignore error if revision was committed successfully', async () => {
332
+ apiService.commitDraftRevision = jest
333
+ .fn()
334
+ .mockRejectedValue(new Error('Revision to commit must be a draft'));
335
+ apiService.isRevisionUploaded = jest.fn().mockResolvedValue(true);
336
+
337
+ await manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes);
338
+
339
+ expect(apiService.commitDraftRevision).toHaveBeenCalledWith(
340
+ nodeRevisionDraft.nodeRevisionUid,
341
+ expect.anything(),
342
+ );
343
+ expect(nodesService.notifyNodeChanged).toHaveBeenCalled();
344
+ });
345
+
346
+ it('should throw error if revision was not committed successfully', async () => {
347
+ apiService.commitDraftRevision = jest
348
+ .fn()
349
+ .mockRejectedValue(new Error('Revision to commit must be a draft'));
350
+ apiService.isRevisionUploaded = jest.fn().mockResolvedValue(false);
351
+
352
+ await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow(
353
+ 'Revision to commit must be a draft',
354
+ );
355
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
356
+ });
357
+
358
+ it('should throw original error if revision cannot be verified', async () => {
359
+ apiService.commitDraftRevision = jest.fn().mockRejectedValue(new Error('Failed to commit revision'));
360
+ apiService.isRevisionUploaded = jest.fn().mockRejectedValue(new Error('Failed to verify revision'));
361
+
362
+ await expect(manager.commitDraft(nodeRevisionDraft as any, manifest, extendedAttributes)).rejects.toThrow(
363
+ 'Failed to commit revision',
364
+ );
365
+ expect(nodesService.notifyNodeChanged).not.toHaveBeenCalled();
366
+ });
330
367
  });
331
368
  });
@@ -246,7 +246,23 @@ export class UploadManager {
246
246
  manifest,
247
247
  generatedExtendedAttributes,
248
248
  );
249
- await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto);
249
+ try {
250
+ await this.apiService.commitDraftRevision(nodeRevisionDraft.nodeRevisionUid, nodeCommitCrypto);
251
+ } catch (error: unknown) {
252
+ // Commit might be sent but due to network error no response is
253
+ // received. In this case, API service automatically retries the
254
+ // request. If the first attempt passed, it will fail on the second
255
+ // attempt. We need to check if the revision was actually committed.
256
+ try {
257
+ const isRevisionUploaded = await this.apiService.isRevisionUploaded(nodeRevisionDraft.nodeRevisionUid);
258
+ if (!isRevisionUploaded) {
259
+ throw error;
260
+ }
261
+ } catch {
262
+ throw error; // Throw original error, not the checking one.
263
+ }
264
+ this.logger.warn(`Node commit failed but node was committed successfully ${nodeRevisionDraft.nodeUid}`);
265
+ }
250
266
  await this.notifyNodeUploaded(nodeRevisionDraft);
251
267
  }
252
268
 
@@ -0,0 +1,130 @@
1
+ import { AbortError } from '../../errors';
2
+ import { UploadQueue } from './queue';
3
+ import { FILE_CHUNK_SIZE } from './streamUploader';
4
+
5
+ describe('UploadQueue', () => {
6
+ let queue: UploadQueue;
7
+
8
+ beforeEach(() => {
9
+ queue = new UploadQueue();
10
+ jest.useFakeTimers();
11
+ });
12
+
13
+ afterEach(() => {
14
+ jest.useRealTimers();
15
+ });
16
+
17
+ it('should resolve immediately when queue is empty', async () => {
18
+ const promise = queue.waitForCapacity(0);
19
+ await promise;
20
+ });
21
+
22
+ it('should resolve immediately when under file upload limit', async () => {
23
+ // Fill queue with 4 uploads (limit is 5)
24
+ for (let i = 0; i < 4; i++) {
25
+ await queue.waitForCapacity(0);
26
+ }
27
+
28
+ const promise = queue.waitForCapacity(0);
29
+ await promise;
30
+ });
31
+
32
+ it('should wait when max concurrent file uploads is reached', async () => {
33
+ // Fill queue to max (5 uploads)
34
+ for (let i = 0; i < 5; i++) {
35
+ await queue.waitForCapacity(0);
36
+ }
37
+
38
+ let resolved = false;
39
+ const promise = queue.waitForCapacity(0).then(() => {
40
+ resolved = true;
41
+ });
42
+
43
+ await jest.advanceTimersByTimeAsync(100);
44
+ expect(resolved).toBe(false);
45
+
46
+ queue.releaseCapacity(0);
47
+
48
+ await jest.advanceTimersByTimeAsync(100);
49
+ await promise;
50
+ expect(resolved).toBe(true);
51
+ });
52
+
53
+ it('should wait when max concurrent upload size is reached', async () => {
54
+ // Fill queue with one large file that exceeds size limit
55
+ const largeSize = 10 * FILE_CHUNK_SIZE;
56
+ await queue.waitForCapacity(largeSize);
57
+
58
+ let resolved = false;
59
+ const promise = queue.waitForCapacity(0).then(() => {
60
+ resolved = true;
61
+ });
62
+
63
+ await jest.advanceTimersByTimeAsync(100);
64
+ expect(resolved).toBe(false);
65
+
66
+ queue.releaseCapacity(largeSize);
67
+
68
+ await jest.advanceTimersByTimeAsync(100);
69
+ await promise;
70
+ expect(resolved).toBe(true);
71
+ });
72
+
73
+ it('should track expected size correctly', async () => {
74
+ const size1 = 5 * FILE_CHUNK_SIZE;
75
+ const size2 = 4 * FILE_CHUNK_SIZE;
76
+
77
+ await queue.waitForCapacity(size1);
78
+ await queue.waitForCapacity(size2);
79
+
80
+ // Total is 9 * FILE_CHUNK_SIZE, limit is 10 * FILE_CHUNK_SIZE
81
+ // So next upload should still be allowed immediately
82
+ const promise = queue.waitForCapacity(3 * FILE_CHUNK_SIZE);
83
+ await promise;
84
+
85
+ // But now we're at limit, next one should wait
86
+ let resolved = false;
87
+ const waitingPromise = queue.waitForCapacity(0).then(() => {
88
+ resolved = true;
89
+ });
90
+
91
+ await jest.advanceTimersByTimeAsync(100);
92
+ expect(resolved).toBe(false);
93
+
94
+ queue.releaseCapacity(size1);
95
+ await jest.advanceTimersByTimeAsync(100);
96
+ await waitingPromise;
97
+ expect(resolved).toBe(true);
98
+ });
99
+
100
+ it('should reject when signal is aborted', async () => {
101
+ // Fill queue to max
102
+ for (let i = 0; i < 5; i++) {
103
+ await queue.waitForCapacity(0);
104
+ }
105
+
106
+ const controller = new AbortController();
107
+ const promise = queue.waitForCapacity(0, controller.signal);
108
+
109
+ controller.abort();
110
+
111
+ // Attach rejection handler BEFORE advancing timers to avoid unhandled rejection
112
+ const expectation = expect(promise).rejects.toThrow(AbortError);
113
+ await jest.advanceTimersByTimeAsync(50);
114
+ await expectation;
115
+ });
116
+
117
+ it('should reject immediately if signal is already aborted', async () => {
118
+ // Fill queue to max
119
+ for (let i = 0; i < 5; i++) {
120
+ await queue.waitForCapacity(0);
121
+ }
122
+
123
+ const controller = new AbortController();
124
+ controller.abort();
125
+
126
+ const promise = queue.waitForCapacity(0, controller.signal);
127
+ await expect(promise).rejects.toThrow(AbortError);
128
+ });
129
+ });
130
+
@@ -1,4 +1,23 @@
1
1
  import { waitForCondition } from '../wait';
2
+ import { FILE_CHUNK_SIZE } from './streamUploader';
3
+
4
+ /**
5
+ * Maximum number of concurrent file uploads.
6
+ *
7
+ * It avoids uploading too many files at the same time. The total file size
8
+ * below also limits that, but if the file is empty, we still need to make
9
+ * a reasonable number of requests.
10
+ */
11
+ const MAX_CONCURRENT_FILE_UPLOADS = 5;
12
+
13
+ /**
14
+ * Maximum total file size that can be uploaded concurrently.
15
+ *
16
+ * It avoids uploading too many blocks at the same time, ensuring that on poor
17
+ * connection we don't do too many things at the same time that all fail due
18
+ * to network issues.
19
+ */
20
+ const MAX_CONCURRENT_UPLOAD_SIZE = 10 * FILE_CHUNK_SIZE;
2
21
 
3
22
  /**
4
23
  * A queue that limits the number of concurrent uploads.
@@ -14,18 +33,24 @@ import { waitForCondition } from '../wait';
14
33
  * uploaded. That is something we want to add in the future to be
15
34
  * more performant for many small file uploads.
16
35
  */
17
- const MAX_CONCURRENT_UPLOADS = 5;
18
-
19
36
  export class UploadQueue {
20
- private capacity = 0;
37
+ private totalFileUploads = 0;
38
+
39
+ private totalExpectedSize = 0;
21
40
 
22
- // TODO: use expected size to control the size of the queue
23
- async waitForCapacity(signal?: AbortSignal) {
24
- await waitForCondition(() => this.capacity < MAX_CONCURRENT_UPLOADS, signal);
25
- this.capacity++;
41
+ async waitForCapacity(expectedSize: number, signal?: AbortSignal) {
42
+ await waitForCondition(
43
+ () =>
44
+ this.totalFileUploads < MAX_CONCURRENT_FILE_UPLOADS &&
45
+ this.totalExpectedSize < MAX_CONCURRENT_UPLOAD_SIZE,
46
+ signal,
47
+ );
48
+ this.totalFileUploads++;
49
+ this.totalExpectedSize += expectedSize;
26
50
  }
27
51
 
28
- releaseCapacity() {
29
- this.capacity--;
52
+ releaseCapacity(expectedSize: number) {
53
+ this.totalFileUploads--;
54
+ this.totalExpectedSize -= expectedSize;
30
55
  }
31
56
  }