@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.
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +2 -2
- package/dist/errors.js.map +1 -1
- package/dist/interface/download.d.ts +14 -0
- package/dist/internal/apiService/apiService.js +6 -2
- package/dist/internal/apiService/apiService.js.map +1 -1
- package/dist/internal/apiService/apiService.test.js +9 -1
- package/dist/internal/apiService/apiService.test.js.map +1 -1
- package/dist/internal/download/controller.d.ts +3 -0
- package/dist/internal/download/controller.js +7 -0
- package/dist/internal/download/controller.js.map +1 -1
- package/dist/internal/download/cryptoService.js +9 -2
- package/dist/internal/download/cryptoService.js.map +1 -1
- package/dist/internal/download/fileDownloader.js +9 -3
- package/dist/internal/download/fileDownloader.js.map +1 -1
- package/dist/internal/download/fileDownloader.test.js +14 -11
- package/dist/internal/download/fileDownloader.test.js.map +1 -1
- package/dist/internal/download/interface.d.ts +14 -0
- package/dist/internal/download/interface.js +16 -0
- package/dist/internal/download/interface.js.map +1 -1
- package/dist/internal/download/seekableStream.d.ts +1 -2
- package/dist/internal/download/seekableStream.js +28 -11
- package/dist/internal/download/seekableStream.js.map +1 -1
- package/dist/internal/download/seekableStream.test.js +100 -0
- package/dist/internal/download/seekableStream.test.js.map +1 -1
- package/dist/internal/nodes/apiService.d.ts +1 -0
- package/dist/internal/nodes/apiService.js +3 -0
- package/dist/internal/nodes/apiService.js.map +1 -1
- package/dist/internal/nodes/nodesManagement.d.ts +1 -0
- package/dist/internal/nodes/nodesManagement.js +5 -0
- package/dist/internal/nodes/nodesManagement.js.map +1 -1
- package/dist/internal/photos/index.js +2 -2
- package/dist/internal/photos/index.js.map +1 -1
- package/dist/internal/upload/apiService.d.ts +1 -0
- package/dist/internal/upload/apiService.js +12 -0
- package/dist/internal/upload/apiService.js.map +1 -1
- package/dist/internal/upload/index.js +4 -4
- package/dist/internal/upload/index.js.map +1 -1
- package/dist/internal/upload/manager.js +19 -1
- package/dist/internal/upload/manager.js.map +1 -1
- package/dist/internal/upload/manager.test.js +23 -0
- package/dist/internal/upload/manager.test.js.map +1 -1
- package/dist/internal/upload/queue.d.ts +18 -3
- package/dist/internal/upload/queue.js +27 -8
- package/dist/internal/upload/queue.js.map +1 -1
- package/dist/internal/upload/queue.test.d.ts +1 -0
- package/dist/internal/upload/queue.test.js +103 -0
- package/dist/internal/upload/queue.test.js.map +1 -0
- package/dist/internal/upload/streamUploader.d.ts +3 -0
- package/dist/internal/upload/streamUploader.js +30 -2
- package/dist/internal/upload/streamUploader.js.map +1 -1
- package/dist/internal/upload/streamUploader.test.js +65 -6
- package/dist/internal/upload/streamUploader.test.js.map +1 -1
- package/dist/internal/upload/telemetry.js +2 -3
- package/dist/internal/upload/telemetry.js.map +1 -1
- package/dist/protonDriveClient.js +1 -1
- package/dist/protonDriveClient.js.map +1 -1
- package/package.json +1 -1
- package/src/errors.ts +2 -2
- package/src/interface/download.ts +16 -0
- package/src/internal/apiService/apiService.test.ts +12 -2
- package/src/internal/apiService/apiService.ts +7 -2
- package/src/internal/download/controller.ts +9 -0
- package/src/internal/download/cryptoService.ts +13 -3
- package/src/internal/download/fileDownloader.test.ts +17 -11
- package/src/internal/download/fileDownloader.ts +9 -5
- package/src/internal/download/interface.ts +15 -0
- package/src/internal/download/seekableStream.test.ts +126 -1
- package/src/internal/download/seekableStream.ts +34 -13
- package/src/internal/nodes/apiService.ts +7 -0
- package/src/internal/nodes/nodesManagement.ts +6 -0
- package/src/internal/photos/index.ts +2 -2
- package/src/internal/upload/apiService.ts +25 -0
- package/src/internal/upload/index.ts +4 -4
- package/src/internal/upload/manager.test.ts +37 -0
- package/src/internal/upload/manager.ts +17 -1
- package/src/internal/upload/queue.test.ts +130 -0
- package/src/internal/upload/queue.ts +34 -9
- package/src/internal/upload/streamUploader.test.ts +80 -7
- package/src/internal/upload/streamUploader.ts +34 -2
- package/src/internal/upload/telemetry.ts +2 -3
- 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(
|
|
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
|
-
|
|
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
|
|
37
|
+
private totalFileUploads = 0;
|
|
38
|
+
|
|
39
|
+
private totalExpectedSize = 0;
|
|
21
40
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
52
|
+
releaseCapacity(expectedSize: number) {
|
|
53
|
+
this.totalFileUploads--;
|
|
54
|
+
this.totalExpectedSize -= expectedSize;
|
|
30
55
|
}
|
|
31
56
|
}
|