@koolbase/core 12.17.0 → 12.19.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/cjs/files-controller.d.ts +62 -0
- package/dist/cjs/files-controller.js +160 -0
- package/dist/cjs/files-refresh.d.ts +9 -0
- package/dist/cjs/files-refresh.js +39 -0
- package/dist/cjs/index.d.ts +2 -0
- package/dist/cjs/index.js +6 -1
- package/dist/cjs/react/index.d.ts +1 -0
- package/dist/cjs/react/index.js +3 -1
- package/dist/cjs/react/use-files.d.ts +8 -0
- package/dist/cjs/react/use-files.js +37 -0
- package/dist/cjs/storage-errors.d.ts +14 -0
- package/dist/cjs/storage-errors.js +29 -1
- package/dist/cjs/storage-multipart.d.ts +29 -0
- package/dist/cjs/storage-multipart.js +168 -0
- package/dist/cjs/storage-transfer.d.ts +25 -6
- package/dist/cjs/storage-transfer.js +78 -37
- package/dist/cjs/storage.d.ts +40 -0
- package/dist/cjs/storage.js +205 -15
- package/dist/esm/files-controller.d.ts +62 -0
- package/dist/esm/files-controller.js +155 -0
- package/dist/esm/files-refresh.d.ts +9 -0
- package/dist/esm/files-refresh.js +34 -0
- package/dist/esm/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/react/index.d.ts +1 -0
- package/dist/esm/react/index.js +1 -0
- package/dist/esm/react/use-files.d.ts +8 -0
- package/dist/esm/react/use-files.js +34 -0
- package/dist/esm/storage-errors.d.ts +14 -0
- package/dist/esm/storage-errors.js +26 -0
- package/dist/esm/storage-multipart.d.ts +29 -0
- package/dist/esm/storage-multipart.js +164 -0
- package/dist/esm/storage-transfer.d.ts +25 -6
- package/dist/esm/storage-transfer.js +77 -37
- package/dist/esm/storage.d.ts +40 -0
- package/dist/esm/storage.js +205 -15
- package/package.json +1 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// A bucket's files read as a list, framework-free (storage S4).
|
|
2
|
+
//
|
|
3
|
+
// The data half of a file gallery: loading, loaded or error, refresh, and
|
|
4
|
+
// the next page by cursor. No React here, so it is tested in this package;
|
|
5
|
+
// useFiles in @koolbase/react-native and @koolbase/js/react is a thin
|
|
6
|
+
// wrapper. Mirrors the Flutter SDK's KoolbaseFilesController.
|
|
7
|
+
//
|
|
8
|
+
// - only a first load with nothing to show is the error state; a failed
|
|
9
|
+
// refresh or loadMore keeps what is shown (stale beats blank)
|
|
10
|
+
// - loadMore appends the next page while there is one; a file already
|
|
11
|
+
// shown is never shown twice (matched by path)
|
|
12
|
+
// - an upload, delete, move, copy, metadata change or restore made through
|
|
13
|
+
// this SDK in the bucket reloads page one silently (files-refresh.ts)
|
|
14
|
+
// - every fetch belongs to a generation: a result from a fetch a later
|
|
15
|
+
// refresh superseded, or one landing after dispose(), is dropped
|
|
16
|
+
import { registerFilesRefresher } from './files-refresh.js';
|
|
17
|
+
export const initialFilesState = Object.freeze({
|
|
18
|
+
status: 'loading',
|
|
19
|
+
files: Object.freeze([]),
|
|
20
|
+
error: null,
|
|
21
|
+
refreshing: false,
|
|
22
|
+
loadingMore: false,
|
|
23
|
+
hasMore: true,
|
|
24
|
+
});
|
|
25
|
+
/** One string per distinct listing. */
|
|
26
|
+
export function filesQueryKey(bucket, query = {}) {
|
|
27
|
+
return JSON.stringify([bucket, query.prefix ?? '', query.sort ?? 'path', query.descending === true, query.limit ?? null]);
|
|
28
|
+
}
|
|
29
|
+
/** Page one, then the later pages without any file already shown. */
|
|
30
|
+
function mergeByPath(first, rest) {
|
|
31
|
+
const seen = new Set(first.map((f) => f.path));
|
|
32
|
+
const out = [...first];
|
|
33
|
+
for (const f of rest) {
|
|
34
|
+
if (seen.has(f.path))
|
|
35
|
+
continue;
|
|
36
|
+
seen.add(f.path);
|
|
37
|
+
out.push(f);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
export class KoolbaseFilesController {
|
|
42
|
+
constructor(storage, bucket, query = {}) {
|
|
43
|
+
this.state = initialFilesState;
|
|
44
|
+
this.listeners = new Set();
|
|
45
|
+
this.generation = 0;
|
|
46
|
+
this.disposed = false;
|
|
47
|
+
this.pageOne = [];
|
|
48
|
+
this.laterPages = [];
|
|
49
|
+
this.cursor = null;
|
|
50
|
+
this.unregister = null;
|
|
51
|
+
this.storage = storage;
|
|
52
|
+
this.bucket = bucket;
|
|
53
|
+
this.spec = query;
|
|
54
|
+
}
|
|
55
|
+
getState() {
|
|
56
|
+
return this.state;
|
|
57
|
+
}
|
|
58
|
+
/** Called on every change. Returns the unsubscribe. */
|
|
59
|
+
subscribe(listener) {
|
|
60
|
+
if (this.disposed)
|
|
61
|
+
return () => { };
|
|
62
|
+
this.listeners.add(listener);
|
|
63
|
+
return () => {
|
|
64
|
+
this.listeners.delete(listener);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The first load. Call once; refresh() for later loads. */
|
|
68
|
+
async load() {
|
|
69
|
+
if (this.disposed)
|
|
70
|
+
return;
|
|
71
|
+
this.unregister ?? (this.unregister = registerFilesRefresher(this.bucket, () => this.firstPage()));
|
|
72
|
+
await this.firstPage();
|
|
73
|
+
}
|
|
74
|
+
/** Page one again. What is shown stays while it runs, and stays if it fails. */
|
|
75
|
+
async refresh() {
|
|
76
|
+
if (this.disposed)
|
|
77
|
+
return;
|
|
78
|
+
this.set({ refreshing: true });
|
|
79
|
+
await this.firstPage();
|
|
80
|
+
}
|
|
81
|
+
/** The next page, appended. Does nothing unless loaded with a page to come. */
|
|
82
|
+
async loadMore() {
|
|
83
|
+
const s = this.state;
|
|
84
|
+
if (this.disposed || s.loadingMore || !s.hasMore || s.status !== 'loaded' || this.cursor === null)
|
|
85
|
+
return;
|
|
86
|
+
const gen = this.generation;
|
|
87
|
+
this.set({ loadingMore: true });
|
|
88
|
+
try {
|
|
89
|
+
const page = await this.storage.listFiles(this.options(this.cursor));
|
|
90
|
+
if (this.isStale(gen))
|
|
91
|
+
return;
|
|
92
|
+
this.laterPages = [...this.laterPages, ...page.files];
|
|
93
|
+
this.cursor = page.nextCursor;
|
|
94
|
+
this.publish({});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Keep what is shown; the caller can try again.
|
|
98
|
+
}
|
|
99
|
+
finally {
|
|
100
|
+
if (!this.isStale(gen))
|
|
101
|
+
this.set({ loadingMore: false });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Stops all updates. Results still in flight are dropped when they land. */
|
|
105
|
+
dispose() {
|
|
106
|
+
this.disposed = true;
|
|
107
|
+
this.unregister?.();
|
|
108
|
+
this.unregister = null;
|
|
109
|
+
this.listeners.clear();
|
|
110
|
+
}
|
|
111
|
+
async firstPage() {
|
|
112
|
+
const gen = ++this.generation;
|
|
113
|
+
if (this.state.loadingMore)
|
|
114
|
+
this.set({ loadingMore: false });
|
|
115
|
+
try {
|
|
116
|
+
const page = await this.storage.listFiles(this.options(undefined));
|
|
117
|
+
if (!this.isStale(gen)) {
|
|
118
|
+
this.pageOne = page.files;
|
|
119
|
+
this.laterPages = [];
|
|
120
|
+
this.cursor = page.nextCursor;
|
|
121
|
+
this.publish({ status: 'loaded', error: null, refreshing: false });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// Only a first load with nothing to show is the error state. Whichever
|
|
126
|
+
// fetch is newest ends a refresh, a silent one included.
|
|
127
|
+
if (!this.isStale(gen)) {
|
|
128
|
+
this.set(this.state.files.length === 0 ? { status: 'error', error, refreshing: false } : { refreshing: false });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
publish(patch) {
|
|
133
|
+
this.set({ ...patch, files: mergeByPath(this.pageOne, this.laterPages), hasMore: this.cursor !== null });
|
|
134
|
+
}
|
|
135
|
+
options(cursor) {
|
|
136
|
+
return {
|
|
137
|
+
bucket: this.bucket,
|
|
138
|
+
...(this.spec.prefix ? { prefix: this.spec.prefix } : {}),
|
|
139
|
+
...(this.spec.limit != null ? { limit: this.spec.limit } : {}),
|
|
140
|
+
...(this.spec.sort ? { sort: this.spec.sort } : {}),
|
|
141
|
+
...(this.spec.descending ? { descending: true } : {}),
|
|
142
|
+
...(cursor ? { cursor } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
isStale(gen) {
|
|
146
|
+
return this.disposed || gen !== this.generation;
|
|
147
|
+
}
|
|
148
|
+
set(patch) {
|
|
149
|
+
if (this.disposed)
|
|
150
|
+
return;
|
|
151
|
+
this.state = Object.freeze({ ...this.state, ...patch });
|
|
152
|
+
for (const listener of [...this.listeners])
|
|
153
|
+
listener();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Registers one file list's reload; returns the unregister. */
|
|
2
|
+
export declare function registerFilesRefresher(bucket: string, refresh: () => Promise<void>): () => void;
|
|
3
|
+
/**
|
|
4
|
+
* Reloads every file list on [buckets]. One after another; a failure in one
|
|
5
|
+
* never stops the others, and never reaches the change that caused it.
|
|
6
|
+
*/
|
|
7
|
+
export declare function refreshBucketFiles(...buckets: string[]): Promise<void>;
|
|
8
|
+
/** Tests only. */
|
|
9
|
+
export declare function clearFilesRefreshers(): void;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// File lists reloaded after a change made through this SDK (storage S4): an
|
|
2
|
+
// upload, a delete, a move or copy, a metadata change, a restore. Keyed by
|
|
3
|
+
// bucket; each list reloads ITSELF, with its own prefix and order.
|
|
4
|
+
const refreshers = new Map();
|
|
5
|
+
let next = 0;
|
|
6
|
+
/** Registers one file list's reload; returns the unregister. */
|
|
7
|
+
export function registerFilesRefresher(bucket, refresh) {
|
|
8
|
+
const key = `${bucket}\u0000${++next}`;
|
|
9
|
+
refreshers.set(key, refresh);
|
|
10
|
+
return () => {
|
|
11
|
+
refreshers.delete(key);
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Reloads every file list on [buckets]. One after another; a failure in one
|
|
16
|
+
* never stops the others, and never reaches the change that caused it.
|
|
17
|
+
*/
|
|
18
|
+
export async function refreshBucketFiles(...buckets) {
|
|
19
|
+
const prefixes = buckets.map((b) => `${b}\u0000`);
|
|
20
|
+
const due = [...refreshers.entries()].filter(([k]) => prefixes.some((p) => k.startsWith(p))).map(([, f]) => f);
|
|
21
|
+
for (const refresh of due) {
|
|
22
|
+
try {
|
|
23
|
+
await refresh();
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.warn('[Koolbase] A file list failed to reload after a change:', e);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Tests only. */
|
|
32
|
+
export function clearFilesRefreshers() {
|
|
33
|
+
refreshers.clear();
|
|
34
|
+
}
|
package/dist/esm/index.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export { KoolbaseStorage } from './storage.js';
|
|
|
23
23
|
export { KoolbaseUploadTask } from './storage-transfer.js';
|
|
24
24
|
export type { KoolbaseUploadProgress, KoolbaseUploadState } from './storage-transfer.js';
|
|
25
25
|
export type { KoolbaseFileInfo, KoolbaseFilePage, KoolbaseFileSort, ListFilesOptions } from './storage-files.js';
|
|
26
|
+
export { KoolbaseFilesController, initialFilesState, filesQueryKey } from './files-controller.js';
|
|
27
|
+
export type { FilesQuery, FilesState, FilesStatus } from './files-controller.js';
|
|
26
28
|
export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
|
|
27
29
|
export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
|
|
28
30
|
export type { FiscalIntentResult, FiscalStatus } from './fiscal.js';
|
package/dist/esm/index.js
CHANGED
|
@@ -23,6 +23,8 @@ export { KoolbaseRealtime } from './realtime.js';
|
|
|
23
23
|
export { KoolbaseConnectivity } from './connectivity.js';
|
|
24
24
|
export { KoolbaseStorage } from './storage.js';
|
|
25
25
|
export { KoolbaseUploadTask } from './storage-transfer.js';
|
|
26
|
+
// A bucket's files as list state (storage S4).
|
|
27
|
+
export { KoolbaseFilesController, initialFilesState, filesQueryKey } from './files-controller.js';
|
|
26
28
|
export { KoolbaseAnalytics, disabledAnalytics } from './analytics.js';
|
|
27
29
|
export { KoolbaseFiscal, KoolbaseFiscalError, decodeFiscalResponse, isFiscalized, isPending } from './fiscal.js';
|
|
28
30
|
export { KoolbaseMessaging } from './messaging.js';
|
package/dist/esm/react/index.js
CHANGED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type FilesQuery, type FilesState, type KoolbaseStorage } from '../index.js';
|
|
2
|
+
export interface UseFilesResult extends FilesState {
|
|
3
|
+
/** Page one again. What is shown stays while it runs, and stays if it fails. */
|
|
4
|
+
refresh: () => Promise<void>;
|
|
5
|
+
/** The next page, appended, while hasMore. */
|
|
6
|
+
loadMore: () => Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createUseFiles(getStorage: () => Pick<KoolbaseStorage, 'listFiles'>): (bucket: string, query?: FilesQuery) => UseFilesResult;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// useFiles: a bucket's files as React state (storage S4).
|
|
2
|
+
//
|
|
3
|
+
// A thin wrapper. Everything about getting the files right lives in
|
|
4
|
+
// @koolbase/core's KoolbaseFilesController, which is tested there; this
|
|
5
|
+
// file only ties one controller's lifetime to a component's.
|
|
6
|
+
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
7
|
+
import { KoolbaseFilesController, filesQueryKey, initialFilesState, } from '../index.js';
|
|
8
|
+
const noop = () => { };
|
|
9
|
+
const done = () => Promise.resolve();
|
|
10
|
+
export function createUseFiles(getStorage) {
|
|
11
|
+
return function useFiles(bucket, query = {}) {
|
|
12
|
+
// The query as a value: an inline object is a new object every render,
|
|
13
|
+
// and must not start a new fetch every render.
|
|
14
|
+
const key = filesQueryKey(bucket, query);
|
|
15
|
+
const [current, setCurrent] = useState(null);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
// Created here, not during render, so React strict mode's second run
|
|
18
|
+
// never keeps a disposed controller.
|
|
19
|
+
const controller = new KoolbaseFilesController(getStorage(), bucket, query);
|
|
20
|
+
setCurrent({ key, controller });
|
|
21
|
+
void controller.load();
|
|
22
|
+
return () => controller.dispose();
|
|
23
|
+
}, [key]);
|
|
24
|
+
// A controller from the previous key reads as a fresh load, never as
|
|
25
|
+
// the previous listing's files.
|
|
26
|
+
const controller = current?.key === key ? current.controller : null;
|
|
27
|
+
const subscribe = useCallback((onChange) => (controller ? controller.subscribe(onChange) : noop), [controller]);
|
|
28
|
+
const getSnapshot = useCallback(() => (controller ? controller.getState() : initialFilesState), [controller]);
|
|
29
|
+
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
30
|
+
const refresh = useCallback(() => (controller ? controller.refresh() : done()), [controller]);
|
|
31
|
+
const loadMore = useCallback(() => (controller ? controller.loadMore() : done()), [controller]);
|
|
32
|
+
return { ...state, refresh, loadMore };
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -226,6 +226,20 @@ export declare class KoolbaseStorageInvalidCursorError extends KoolbaseStorageEr
|
|
|
226
226
|
export declare class KoolbaseStorageSignInRequiredError extends KoolbaseStorageError {
|
|
227
227
|
constructor(message?: string);
|
|
228
228
|
}
|
|
229
|
+
/**
|
|
230
|
+
* Completing an upload in parts with parts missing or the wrong size (S3):
|
|
231
|
+
* resume it, which sends `missing`. Code: `upload_incomplete`.
|
|
232
|
+
*/
|
|
233
|
+
export declare class KoolbaseUploadIncompleteError extends KoolbaseStorageError {
|
|
234
|
+
missing: number[];
|
|
235
|
+
constructor(message?: string, missing?: number[]);
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* A download stopped by its `signal` (S3). Code: `download_cancelled`.
|
|
239
|
+
*/
|
|
240
|
+
export declare class KoolbaseDownloadCancelledError extends KoolbaseStorageError {
|
|
241
|
+
constructor(message?: string);
|
|
242
|
+
}
|
|
229
243
|
/**
|
|
230
244
|
* A move refused while records point at the file (S1b): `field` holds its
|
|
231
245
|
* path in `records` records. Change those first, or copy the file. Code:
|
|
@@ -293,6 +293,28 @@ export class KoolbaseStorageSignInRequiredError extends KoolbaseStorageError {
|
|
|
293
293
|
Object.setPrototypeOf(this, KoolbaseStorageSignInRequiredError.prototype);
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
|
+
/**
|
|
297
|
+
* Completing an upload in parts with parts missing or the wrong size (S3):
|
|
298
|
+
* resume it, which sends `missing`. Code: `upload_incomplete`.
|
|
299
|
+
*/
|
|
300
|
+
export class KoolbaseUploadIncompleteError extends KoolbaseStorageError {
|
|
301
|
+
constructor(message, missing = []) {
|
|
302
|
+
super(message ?? 'The upload is missing parts; resume it', 'upload_incomplete');
|
|
303
|
+
this.missing = missing;
|
|
304
|
+
this.name = 'KoolbaseUploadIncompleteError';
|
|
305
|
+
Object.setPrototypeOf(this, KoolbaseUploadIncompleteError.prototype);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* A download stopped by its `signal` (S3). Code: `download_cancelled`.
|
|
310
|
+
*/
|
|
311
|
+
export class KoolbaseDownloadCancelledError extends KoolbaseStorageError {
|
|
312
|
+
constructor(message) {
|
|
313
|
+
super(message ?? 'The download was cancelled', 'download_cancelled');
|
|
314
|
+
this.name = 'KoolbaseDownloadCancelledError';
|
|
315
|
+
Object.setPrototypeOf(this, KoolbaseDownloadCancelledError.prototype);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
296
318
|
/**
|
|
297
319
|
* A move refused while records point at the file (S1b): `field` holds its
|
|
298
320
|
* path in `records` records. Change those first, or copy the file. Code:
|
|
@@ -372,6 +394,10 @@ export function koolbaseStorageError(status, body, fallbackMessage = 'Storage re
|
|
|
372
394
|
}
|
|
373
395
|
case 'name_has_history':
|
|
374
396
|
return new KoolbaseStorageNameHasHistoryError(message);
|
|
397
|
+
case 'upload_incomplete': {
|
|
398
|
+
const d = (body?.details ?? {});
|
|
399
|
+
return new KoolbaseUploadIncompleteError(message, Array.isArray(d.missing) ? d.missing : []);
|
|
400
|
+
}
|
|
375
401
|
}
|
|
376
402
|
// ─── status fallback (pre-code servers or uncoded paths) ───
|
|
377
403
|
switch (status) {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { KoolbaseUploadTask } from './storage-transfer.js';
|
|
2
|
+
/** The server's plan for an upload in parts, and which parts arrived. */
|
|
3
|
+
export interface MultipartPlan {
|
|
4
|
+
upload_id: string;
|
|
5
|
+
path: string;
|
|
6
|
+
size: number;
|
|
7
|
+
part_size: number;
|
|
8
|
+
part_count: number;
|
|
9
|
+
content_type: string;
|
|
10
|
+
uploaded?: number[];
|
|
11
|
+
}
|
|
12
|
+
/** What sending the parts needs from the server. */
|
|
13
|
+
export interface MultipartApi {
|
|
14
|
+
links(parts: number[]): Promise<Record<string, string>>;
|
|
15
|
+
abort(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** Parts in flight at once. */
|
|
18
|
+
export declare const PARTS_AT_ONCE = 3;
|
|
19
|
+
/** Tries per part. */
|
|
20
|
+
export declare const PART_TRIES = 4;
|
|
21
|
+
/** How long to wait before try `n` (1-based) of a part; tests shorten it. */
|
|
22
|
+
export declare const partRetry: {
|
|
23
|
+
delayMs: (n: number) => number;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Sends the parts of `file` not yet in `plan.uploaded`, reporting the bytes
|
|
27
|
+
* of the whole file; resolves once every part has arrived.
|
|
28
|
+
*/
|
|
29
|
+
export declare function sendParts(task: KoolbaseUploadTask, plan: MultipartPlan, file: Blob, api: MultipartApi): Promise<void>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// A large file's upload, in parts (S3).
|
|
2
|
+
//
|
|
3
|
+
// Each part goes straight to storage on its own signed link, three at a time.
|
|
4
|
+
// A part that fails for a passing reason -- the network, storage busy -- is
|
|
5
|
+
// sent again, up to four times, waiting longer each time; one whose link
|
|
6
|
+
// has expired gets a new link. A failed upload is not abandoned: its id
|
|
7
|
+
// (`KoolbaseUploadTask.uploadId`) resumes it, sending only the parts that
|
|
8
|
+
// did not arrive. Cancelling abandons it.
|
|
9
|
+
import { KoolbaseStorageError, KoolbaseUploadCancelledError } from './storage-errors.js';
|
|
10
|
+
import { transferBytes } from './storage-transfer.js';
|
|
11
|
+
/** Parts in flight at once. */
|
|
12
|
+
export const PARTS_AT_ONCE = 3;
|
|
13
|
+
/** Tries per part. */
|
|
14
|
+
export const PART_TRIES = 4;
|
|
15
|
+
/** Links asked for at a time (the server signs at most 100 a call). */
|
|
16
|
+
const LINKS_AT_ONCE = 50;
|
|
17
|
+
/** How long to wait before try `n` (1-based) of a part; tests shorten it. */
|
|
18
|
+
export const partRetry = { delayMs: (n) => 500 * 2 ** (n - 1) };
|
|
19
|
+
function sleep(ms) {
|
|
20
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
21
|
+
}
|
|
22
|
+
/** A status a part may be sent again after. */
|
|
23
|
+
const passing = (status) => status === 408 || status === 429 || status >= 500;
|
|
24
|
+
/**
|
|
25
|
+
* Sends the parts of `file` not yet in `plan.uploaded`, reporting the bytes
|
|
26
|
+
* of the whole file; resolves once every part has arrived.
|
|
27
|
+
*/
|
|
28
|
+
export async function sendParts(task, plan, file, api) {
|
|
29
|
+
const count = plan.part_count;
|
|
30
|
+
const partSize = plan.part_size;
|
|
31
|
+
const bounds = (n) => [(n - 1) * partSize, Math.min(n * partSize, plan.size)];
|
|
32
|
+
const arrived = new Set(plan.uploaded ?? []);
|
|
33
|
+
const sent = new Map();
|
|
34
|
+
for (const n of arrived) {
|
|
35
|
+
const [a, b] = bounds(n);
|
|
36
|
+
sent.set(n, b - a);
|
|
37
|
+
}
|
|
38
|
+
const report = () => {
|
|
39
|
+
if (task.cancelled)
|
|
40
|
+
return;
|
|
41
|
+
let total = 0;
|
|
42
|
+
for (const v of sent.values())
|
|
43
|
+
total += v;
|
|
44
|
+
task.emit('uploading', Math.min(total, plan.size));
|
|
45
|
+
};
|
|
46
|
+
report();
|
|
47
|
+
const todo = [];
|
|
48
|
+
for (let n = 1; n <= count; n++)
|
|
49
|
+
if (!arrived.has(n))
|
|
50
|
+
todo.push(n);
|
|
51
|
+
// Links, asked for a few at a time -- one request for parts in flight at
|
|
52
|
+
// once, not one each -- and renewed when one has expired.
|
|
53
|
+
const links = new Map();
|
|
54
|
+
const asking = new Map();
|
|
55
|
+
const ask = (parts) => {
|
|
56
|
+
const p = api.links(parts).then((got) => {
|
|
57
|
+
for (const [k, v] of Object.entries(got))
|
|
58
|
+
links.set(Number(k), v);
|
|
59
|
+
});
|
|
60
|
+
for (const n of parts)
|
|
61
|
+
asking.set(n, p);
|
|
62
|
+
return p.finally(() => {
|
|
63
|
+
for (const n of parts)
|
|
64
|
+
if (asking.get(n) === p)
|
|
65
|
+
asking.delete(n);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
const linkFor = async (n, fresh = false) => {
|
|
69
|
+
if (fresh) {
|
|
70
|
+
links.delete(n);
|
|
71
|
+
await ask([n]);
|
|
72
|
+
}
|
|
73
|
+
else if (!links.has(n)) {
|
|
74
|
+
if (asking.has(n))
|
|
75
|
+
await asking.get(n);
|
|
76
|
+
else {
|
|
77
|
+
const from = Math.max(todo.indexOf(n), 0);
|
|
78
|
+
await ask(todo.slice(from, from + LINKS_AT_ONCE).filter((p) => p === n || (!links.has(p) && !asking.has(p))));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const url = links.get(n);
|
|
82
|
+
if (!url)
|
|
83
|
+
throw new KoolbaseStorageError(`No upload link for part ${n}`);
|
|
84
|
+
return url;
|
|
85
|
+
};
|
|
86
|
+
// Every transfer in flight, so a cancel stops them all.
|
|
87
|
+
const flying = new Set();
|
|
88
|
+
task.setAbort(() => {
|
|
89
|
+
for (const t of flying)
|
|
90
|
+
t.abort();
|
|
91
|
+
});
|
|
92
|
+
const sendOne = async (n) => {
|
|
93
|
+
const [a, b] = bounds(n);
|
|
94
|
+
const body = file.slice(a, b);
|
|
95
|
+
let fresh = false;
|
|
96
|
+
for (let attempt = 1;; attempt++) {
|
|
97
|
+
task.checkCancelled();
|
|
98
|
+
const url = await linkFor(n, fresh);
|
|
99
|
+
fresh = false;
|
|
100
|
+
task.checkCancelled();
|
|
101
|
+
sent.set(n, 0);
|
|
102
|
+
report();
|
|
103
|
+
const t = transferBytes(url, body, null, (s) => {
|
|
104
|
+
sent.set(n, s);
|
|
105
|
+
report();
|
|
106
|
+
});
|
|
107
|
+
flying.add(t);
|
|
108
|
+
let status = 0;
|
|
109
|
+
let failure = null;
|
|
110
|
+
try {
|
|
111
|
+
status = (await t.done).status;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
failure = e;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
flying.delete(t);
|
|
118
|
+
}
|
|
119
|
+
task.checkCancelled();
|
|
120
|
+
if (failure === null && status >= 200 && status < 300) {
|
|
121
|
+
sent.set(n, b - a);
|
|
122
|
+
report();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (failure instanceof KoolbaseUploadCancelledError)
|
|
126
|
+
throw failure;
|
|
127
|
+
sent.set(n, 0);
|
|
128
|
+
report();
|
|
129
|
+
const again = failure !== null || passing(status) || status === 403;
|
|
130
|
+
if (!again || attempt >= PART_TRIES) {
|
|
131
|
+
throw failure ?? new KoolbaseStorageError(`Upload of part ${n} failed: ${status}`);
|
|
132
|
+
}
|
|
133
|
+
// An expired link (403) is renewed; anything else waits and tries again.
|
|
134
|
+
if (status === 403)
|
|
135
|
+
fresh = true;
|
|
136
|
+
else
|
|
137
|
+
await sleep(partRetry.delayMs(attempt));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
let next = 0;
|
|
142
|
+
const worker = async () => {
|
|
143
|
+
while (next < todo.length) {
|
|
144
|
+
const n = todo[next++];
|
|
145
|
+
await sendOne(n);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
await Promise.all(Array.from({ length: Math.min(PARTS_AT_ONCE, todo.length) }, worker));
|
|
149
|
+
}
|
|
150
|
+
catch (e) {
|
|
151
|
+
// One part failing stops the rest; the parts that arrived stay, for a
|
|
152
|
+
// resume -- unless the upload was cancelled, which abandons it.
|
|
153
|
+
for (const t of flying)
|
|
154
|
+
t.abort();
|
|
155
|
+
if (task.cancelled) {
|
|
156
|
+
await api.abort().catch(() => undefined);
|
|
157
|
+
throw new KoolbaseUploadCancelledError();
|
|
158
|
+
}
|
|
159
|
+
throw e;
|
|
160
|
+
}
|
|
161
|
+
finally {
|
|
162
|
+
task.setAbort(null);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -33,6 +33,7 @@ export declare class KoolbaseUploadTask {
|
|
|
33
33
|
private cancelRequested;
|
|
34
34
|
private abortTransfer;
|
|
35
35
|
private total;
|
|
36
|
+
private _uploadId;
|
|
36
37
|
/**
|
|
37
38
|
* The uploaded file. Rejects with `KoolbaseUploadCancelledError` if it was
|
|
38
39
|
* cancelled, or the error that made it fail.
|
|
@@ -44,6 +45,15 @@ export declare class KoolbaseUploadTask {
|
|
|
44
45
|
constructor();
|
|
45
46
|
/** Where the upload is now. */
|
|
46
47
|
get current(): KoolbaseUploadProgress;
|
|
48
|
+
/**
|
|
49
|
+
* A large file's upload goes in parts, and can be resumed for a day: keep
|
|
50
|
+
* this id, and pass it to `KoolbaseStorage.resumeUpload` with the same
|
|
51
|
+
* file after the app restarts or the upload fails. Null for a file sent
|
|
52
|
+
* in one piece, and until the upload has begun.
|
|
53
|
+
*/
|
|
54
|
+
get uploadId(): string | null;
|
|
55
|
+
/** @internal */
|
|
56
|
+
setUploadId(id: string): void;
|
|
47
57
|
/**
|
|
48
58
|
* Calls `listener` on every change of state and every chunk sent, starting
|
|
49
59
|
* with where the upload is now. Returns a function that stops it.
|
|
@@ -74,15 +84,24 @@ export declare class KoolbaseUploadTask {
|
|
|
74
84
|
}
|
|
75
85
|
/** Runs `body` as the task's upload, starting on the next turn so a caller can listen to every event. */
|
|
76
86
|
export declare function runUploadTask(body: (task: KoolbaseUploadTask) => Promise<UploadResult>): KoolbaseUploadTask;
|
|
77
|
-
interface PutResult {
|
|
87
|
+
export interface PutResult {
|
|
78
88
|
status: number;
|
|
79
89
|
etag: string;
|
|
80
90
|
}
|
|
91
|
+
/** A PUT under way: its outcome, and how to stop it. */
|
|
92
|
+
export interface Transfer {
|
|
93
|
+
done: Promise<PutResult>;
|
|
94
|
+
abort(): void;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* PUTs `body` to `url`, reporting bytes as they are sent. XMLHttpRequest
|
|
98
|
+
* where the host has it (browsers and React Native both report upload
|
|
99
|
+
* progress); fetch elsewhere, where progress jumps from 0 to the total.
|
|
100
|
+
* An aborted transfer rejects with `KoolbaseUploadCancelledError`.
|
|
101
|
+
*/
|
|
102
|
+
export declare function transferBytes(url: string, body: Blob, contentType: string | null, onSent: (sent: number) => void): Transfer;
|
|
81
103
|
/**
|
|
82
|
-
* Sends `body` to a presigned `url` with PUT
|
|
83
|
-
*
|
|
84
|
-
* both report upload progress); fetch elsewhere, where progress jumps from
|
|
85
|
-
* 0 to the total. A cancel aborts it.
|
|
104
|
+
* Sends `body` to a presigned `url` with PUT as the task's one transfer,
|
|
105
|
+
* reporting bytes as they are sent; a cancel aborts it.
|
|
86
106
|
*/
|
|
87
107
|
export declare function putWithProgress(task: KoolbaseUploadTask, url: string, body: Blob, contentType: string): Promise<PutResult>;
|
|
88
|
-
export {};
|