@gentleduck/upload 0.2.5 → 0.3.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/README.md +297 -0
- package/dist/core/index.d.ts +2 -3
- package/dist/core/index.js +1 -1
- package/dist/core-BVgloTiP.js +10 -0
- package/dist/index-DCqyBVcx.d.ts +1441 -0
- package/dist/index-DsZHPmJm.d.ts +225 -0
- package/dist/index-KROXtLQh.d.ts +281 -0
- package/dist/index.d.ts +4 -5
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +2 -90
- package/dist/react/index.js +1 -1
- package/dist/react-euPrDv24.js +1 -0
- package/dist/strategies/index.d.ts +2 -56
- package/dist/strategies/index.js +1 -1
- package/dist/strategies-CUF0s68j.js +1 -0
- package/package.json +3 -2
- package/src/__tests__/emitter.test.ts +85 -0
- package/src/__tests__/fingerprint.test.ts +49 -0
- package/src/__tests__/guards.test.ts +40 -0
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/__tests__/persistence.test.ts +191 -0
- package/src/__tests__/sanitize-filename.test.ts +109 -0
- package/src/__tests__/sleep.test.ts +34 -0
- package/src/__tests__/static-analysis.test.ts +145 -0
- package/src/__tests__/store-libs.test.ts +192 -0
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/__tests__/transport-libs.test.ts +42 -0
- package/src/__tests__/transport.test.ts +182 -0
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +373 -0
- package/src/core/contracts/index.ts +3 -8
- package/src/core/contracts/transport/index.ts +3 -2
- package/src/core/contracts/transport/transport.fetch.ts +203 -0
- package/src/core/contracts/transport/transport.libs.ts +13 -39
- package/src/core/contracts/transport/transport.ts +96 -90
- package/src/core/contracts/transport/transport.types.ts +71 -35
- package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
- package/src/core/engine/engine.libs.ts +35 -0
- package/src/core/engine/engine.types.ts +490 -0
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +93 -53
- package/src/core/engine/store/handlers/add-file.ts +43 -48
- package/src/core/engine/store/handlers/cancel.ts +13 -8
- package/src/core/engine/store/handlers/clean-up.ts +11 -8
- package/src/core/engine/store/handlers/create-intent.ts +82 -27
- package/src/core/engine/store/handlers/finalize.ts +30 -13
- package/src/core/engine/store/handlers/index.ts +7 -7
- package/src/core/engine/store/handlers/pause.ts +8 -6
- package/src/core/engine/store/handlers/run-upload.ts +54 -39
- package/src/core/engine/store/index.ts +27 -21
- package/src/core/engine/store/store.dispatch.ts +10 -8
- package/src/core/engine/store/store.libs.ts +197 -94
- package/src/core/engine/store/store.runtime.ts +52 -53
- package/src/core/engine/store/store.schedule.ts +27 -21
- package/src/core/engine/store/store.types.ts +177 -227
- package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
- package/src/core/engine/validation/file.ts +8 -4
- package/src/core/engine/validation/index.ts +1 -0
- package/src/core/engine/validation/intent.ts +49 -27
- package/src/core/engine/validation/list.ts +14 -15
- package/src/core/engine/validation/mime.ts +39 -0
- package/src/core/errors.ts +271 -0
- package/src/core/index.ts +28 -5
- package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
- package/src/core/persistence/__tests__/persistence.test.ts +85 -0
- package/src/core/persistence/adapters.indexeddb.ts +9 -3
- package/src/core/persistence/adapters.local.ts +9 -3
- package/src/core/persistence/adapters.memory.ts +35 -13
- package/src/core/persistence/index.ts +5 -5
- package/src/core/persistence/persistence.ts +73 -60
- package/src/core/persistence/persistence.types.ts +140 -60
- package/src/core/utils/__tests__/guards.test.ts +93 -0
- package/src/core/utils/__tests__/url-safety.test.ts +112 -0
- package/src/core/utils/async.ts +7 -2
- package/src/core/utils/constants.ts +3 -9
- package/src/core/utils/emitter/emitter.ts +126 -0
- package/src/core/utils/emitter/emitter.types.ts +33 -16
- package/src/core/utils/emitter/index.ts +2 -74
- package/src/core/utils/fingerprint.ts +16 -5
- package/src/core/utils/guards.ts +91 -1
- package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
- package/src/core/utils/hash/index.ts +126 -0
- package/src/core/utils/hash/sha256.ts +179 -0
- package/src/core/utils/id.ts +33 -1
- package/src/core/utils/index.ts +15 -6
- package/src/core/utils/mime-sniff.ts +119 -0
- package/src/core/utils/sanitize-filename.ts +85 -0
- package/src/core/utils/url-safety.ts +156 -0
- package/src/react/__tests__/use-dropzone.test.ts +64 -0
- package/src/react/index.ts +5 -2
- package/src/react/upload-provider.tsx +59 -95
- package/src/react/uploader.types.ts +80 -0
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +97 -93
- package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
- package/src/strategies/_shared/retry.ts +79 -0
- package/src/strategies/index.ts +9 -15
- package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
- package/src/strategies/multipart/index.ts +211 -116
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +113 -71
- package/src/strategies/put/__tests__/put.test.ts +132 -0
- package/src/strategies/put/index.ts +139 -0
- package/src/strategies/registry/index.ts +12 -9
- package/src/strategies/tus/__tests__/tus.test.ts +159 -0
- package/src/strategies/tus/index.ts +211 -0
- package/dist/async-DxRWu4le.js +0 -1
- package/dist/core-BNxznRif.js +0 -2
- package/dist/index-BXg9WIce.d.ts +0 -987
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/core/client/config.types.ts +0 -77
- package/src/core/client/index.ts +0 -4
- package/src/core/client/plugins.types.ts +0 -46
- package/src/core/client/retry.types.ts +0 -6
- package/src/core/client/uploadClient.ts +0 -19
- package/src/core/contracts/backendApi.ts +0 -62
- package/src/core/contracts/cursor.types.ts +0 -23
- package/src/core/contracts/errors.types.ts +0 -67
- package/src/core/contracts/fingerprint.types.ts +0 -18
- package/src/core/contracts/intent.types.ts +0 -39
- package/src/core/contracts/result.types.ts +0 -11
- package/src/core/contracts/strategy.types.ts +0 -45
- package/src/core/engine/commands.types.ts +0 -17
- package/src/core/engine/event-map.types.ts +0 -45
- package/src/core/engine/internal-events.types.ts +0 -222
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
|
@@ -1,159 +1,263 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import type { UploadError } from '../../errors'
|
|
3
3
|
import { DEFAULT_RETRY_DELAY_BASE_MS, DEFAULT_RETRY_DELAY_MAX_MS } from '../../utils/constants'
|
|
4
4
|
import { computeFingerprint, fingerprintMatches } from '../../utils/fingerprint'
|
|
5
|
-
import { isRecord } from '../../utils/guards'
|
|
6
|
-
import
|
|
5
|
+
import { isDevEnv, isRecord } from '../../utils/guards'
|
|
6
|
+
import { hashBlob } from '../../utils/hash'
|
|
7
|
+
import type { Engine } from '../engine.types'
|
|
8
|
+
import type { Store } from './store.types'
|
|
7
9
|
|
|
8
10
|
export { sleep } from '../../utils/async'
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
/** Build enriched API context from a live item for passing to any backend API method. */
|
|
13
|
+
export function buildApiContext<
|
|
14
|
+
M extends Contracts.Intent.Map,
|
|
15
|
+
C extends Contracts.Cursor.Map<M>,
|
|
16
|
+
P extends string,
|
|
17
|
+
R extends Contracts.Result.Base,
|
|
18
|
+
>(item: Engine.Item<M, C, P, R>, overrides: Partial<Contracts.Api.Context<P, M>> = {}): Contracts.Api.Context<P, M> {
|
|
19
|
+
const lastErrorStep = [...item.steps].reverse().find((s) => s.error !== undefined)
|
|
20
|
+
return {
|
|
21
|
+
localId: item.localId,
|
|
22
|
+
purpose: item.purpose,
|
|
23
|
+
fingerprint: item.fingerprint,
|
|
24
|
+
attempt: item.attempt ?? 1,
|
|
25
|
+
createdAt: item.createdAt,
|
|
26
|
+
steps: item.steps,
|
|
27
|
+
meta: item.meta,
|
|
28
|
+
lastError: lastErrorStep?.error,
|
|
29
|
+
file: 'file' in item ? item.file : undefined,
|
|
30
|
+
intent: 'intent' in item ? (item as { intent?: Contracts.Intent.Any<M> }).intent : undefined,
|
|
31
|
+
...overrides,
|
|
32
|
+
}
|
|
33
|
+
}
|
|
11
34
|
|
|
12
35
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
36
|
+
* Default boundary between inline and incremental hashing when
|
|
37
|
+
* `config.checksumMaxSize` is unset (`null`) or `0`: 64 MiB.
|
|
15
38
|
*/
|
|
16
|
-
export
|
|
17
|
-
const buffer = await file.arrayBuffer()
|
|
18
|
-
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
|
|
19
|
-
const hashArray = Array.from(new Uint8Array(hashBuffer))
|
|
20
|
-
return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
21
|
-
}
|
|
39
|
+
export const DEFAULT_CHECKSUM_MAX_SIZE = 64 * 1024 * 1024
|
|
22
40
|
|
|
23
41
|
/**
|
|
24
|
-
*
|
|
42
|
+
* SHA-256 checksum of `file` for deduplication.
|
|
43
|
+
*
|
|
44
|
+
* `maxSize` is the inline/incremental boundary, not a skip: files at or below
|
|
45
|
+
* it are hashed with native Web Crypto (fastest), while larger files are hashed
|
|
46
|
+
* incrementally in bounded slices — off the main thread via a Web Worker when
|
|
47
|
+
* available, otherwise inline-incremental. Memory stays flat either way, so
|
|
48
|
+
* dedupe now covers files of any size.
|
|
25
49
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* - `(purpose: P) => boolean`: custom predicate
|
|
50
|
+
* Pass `maxSize = 0` or omit it to use {@link DEFAULT_CHECKSUM_MAX_SIZE}.
|
|
51
|
+
*
|
|
52
|
+
* @returns lowercase hex SHA-256 digest.
|
|
30
53
|
*/
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
54
|
+
export async function calculateFileChecksum(file: File, maxSize: number | null = null): Promise<string> {
|
|
55
|
+
// Use the caller-supplied boundary only when it's a finite positive number.
|
|
56
|
+
// NaN / Infinity / 0 / negative all fall back to the conservative default.
|
|
57
|
+
const inlineMaxSize =
|
|
58
|
+
typeof maxSize === 'number' && Number.isFinite(maxSize) && maxSize > 0 ? maxSize : DEFAULT_CHECKSUM_MAX_SIZE
|
|
59
|
+
return hashBlob(file, { inlineMaxSize })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve auto-start config: `false | undefined` (never), `P[]` (in list), or
|
|
64
|
+
* `(purpose) => boolean` predicate.
|
|
65
|
+
*/
|
|
66
|
+
export function isAutoStart<
|
|
67
|
+
M extends Contracts.Intent.Map,
|
|
68
|
+
C extends Contracts.Cursor.Map<M>,
|
|
69
|
+
P extends string,
|
|
70
|
+
R extends Contracts.Result.Base,
|
|
71
|
+
>(opts: Store.Options<M, C, P, R>, purpose: P): boolean {
|
|
35
72
|
const v = opts.config?.autoStart
|
|
36
73
|
if (v === undefined) return false
|
|
37
74
|
if (Array.isArray(v)) return v.includes(purpose)
|
|
38
|
-
if (typeof v === 'function')
|
|
75
|
+
if (typeof v === 'function') {
|
|
76
|
+
// Fail-closed if the user-supplied predicate throws so a buggy
|
|
77
|
+
// predicate can't stop the entire addFiles flow.
|
|
78
|
+
try {
|
|
79
|
+
return v(purpose) === true
|
|
80
|
+
} catch {
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
}
|
|
39
84
|
return false
|
|
40
85
|
}
|
|
41
86
|
|
|
42
87
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* (e.g. SHA-256), provide a custom {@link StoreOptions.fingerprint} function or let the
|
|
47
|
-
* add-file handler compute a checksum asynchronously.
|
|
88
|
+
* Lightweight `File` fingerprint for display/identity. For stronger identity
|
|
89
|
+
* (SHA-256), pass {@link StoreOptions.fingerprint} or let add-file compute a
|
|
90
|
+
* checksum asynchronously.
|
|
48
91
|
*/
|
|
49
92
|
export { computeFingerprint, fingerprintMatches }
|
|
50
93
|
|
|
51
94
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* You can provide a custom normalizer through {@link StoreOptions.errorNormalizer}.
|
|
55
|
-
* Built-in behavior:
|
|
56
|
-
* - detects abort errors and marks them non-retryable
|
|
57
|
-
* - classifies common network and HTTP errors as retryable where appropriate
|
|
95
|
+
* When `findByChecksum` matches, complete the item without creating an intent or
|
|
96
|
+
* uploading bytes. Returns `true` when dedupe succeeded.
|
|
58
97
|
*/
|
|
59
|
-
export function
|
|
98
|
+
export async function tryDedupeByChecksum<
|
|
99
|
+
M extends Contracts.Intent.Map,
|
|
100
|
+
C extends Contracts.Cursor.Map<M>,
|
|
101
|
+
P extends string,
|
|
102
|
+
R extends Contracts.Result.Base,
|
|
103
|
+
>(
|
|
104
|
+
rt: Store.Runtime<M, C, P, R>,
|
|
105
|
+
localId: string,
|
|
106
|
+
checksum: string | undefined,
|
|
107
|
+
purpose: P,
|
|
108
|
+
allowedPhases: ReadonlyArray<Engine.Phase>,
|
|
109
|
+
): Promise<boolean> {
|
|
110
|
+
if (!checksum || !rt.opts.api.findByChecksum) return false
|
|
111
|
+
|
|
112
|
+
const item = rt.state.items.get(localId)
|
|
113
|
+
if (!item || !allowedPhases.includes(item.phase) || !item.file) return false
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose }, {
|
|
117
|
+
...buildApiContext(item),
|
|
118
|
+
file: item.file,
|
|
119
|
+
} as Contracts.Api.FindByChecksumContext<P, M>)
|
|
120
|
+
if (!existingFile) return false
|
|
121
|
+
|
|
122
|
+
const current = rt.state.items.get(localId)
|
|
123
|
+
if (!current || !allowedPhases.includes(current.phase)) return false
|
|
124
|
+
|
|
125
|
+
rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
|
|
126
|
+
return true
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isDevEnv()) {
|
|
129
|
+
console.warn('[UploadEngine] Failed to check for existing file:', err)
|
|
130
|
+
}
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Detects when `createIntent` returned an existing file result instead of an
|
|
137
|
+
* upload intent (e.g. backend matched checksum during plan-upload).
|
|
138
|
+
*/
|
|
139
|
+
export function parseCreateIntentDedupeResult<R extends Contracts.Result.Base>(response: unknown): R | null {
|
|
140
|
+
if (!isRecord(response)) return null
|
|
141
|
+
|
|
142
|
+
const strategy = response['strategy']
|
|
143
|
+
const markedDedupe =
|
|
144
|
+
response['dedupe'] === true ||
|
|
145
|
+
response['existing'] === true ||
|
|
146
|
+
response['skipUpload'] === true ||
|
|
147
|
+
strategy === 'dedupe'
|
|
148
|
+
|
|
149
|
+
if (!markedDedupe) return null
|
|
150
|
+
|
|
151
|
+
const fileId =
|
|
152
|
+
typeof response['fileId'] === 'string'
|
|
153
|
+
? response['fileId']
|
|
154
|
+
: typeof response['id'] === 'string'
|
|
155
|
+
? response['id']
|
|
156
|
+
: null
|
|
157
|
+
const key = typeof response['key'] === 'string' ? response['key'] : null
|
|
158
|
+
if (!fileId || !key) return null
|
|
159
|
+
|
|
160
|
+
return { fileId, key } as R
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
import {
|
|
164
|
+
UploadAbortError,
|
|
165
|
+
UploadEngineError,
|
|
166
|
+
UploadHttpError,
|
|
167
|
+
UploadNetworkError,
|
|
168
|
+
UploadUnknownError,
|
|
169
|
+
} from '../../errors'
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Normalize a thrown value into an {@link Contracts.Errors.Error}. Override via
|
|
173
|
+
* {@link StoreOptions.errorNormalizer}. Default: aborts → non-retryable;
|
|
174
|
+
* network errors → retryable; HTTP 5xx/429 → retryable.
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => Contracts.Errors.Error): UploadError {
|
|
60
177
|
if (customNormalizer) return customNormalizer(err)
|
|
61
178
|
|
|
62
|
-
|
|
179
|
+
if (err instanceof UploadEngineError) {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Transport aborts carry `{ code: 'aborted', reason: 'pause' | 'cancel' }`.
|
|
63
184
|
if (isAbortError(err)) {
|
|
64
|
-
|
|
185
|
+
const reason = err.reason === 'pause' || err.reason === 'cancel' ? err.reason : 'unknown'
|
|
186
|
+
return new UploadAbortError(reason, undefined, { cause: err })
|
|
65
187
|
}
|
|
66
188
|
|
|
67
|
-
const msg = isRecord(err) && typeof err
|
|
189
|
+
const msg = isRecord(err) && typeof err['message'] === 'string' ? (err['message'] as string) : 'Unknown error'
|
|
68
190
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
return { code: 'network', message: String(msg), cause: err, retryable: true }
|
|
191
|
+
if (msg.toLowerCase().includes('network') || msg.toLowerCase().includes('fetch')) {
|
|
192
|
+
return new UploadNetworkError(msg, { cause: err })
|
|
72
193
|
}
|
|
73
194
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
return { code: 'http', status, message: String(msg), cause: err, retryable }
|
|
195
|
+
if (isRecord(err) && (typeof err['status'] === 'number' || typeof err['statusCode'] === 'number')) {
|
|
196
|
+
const status = (typeof err['status'] === 'number' ? err['status'] : err['statusCode']) as number
|
|
197
|
+
const statusText = typeof err['statusText'] === 'string' ? err['statusText'] : undefined
|
|
198
|
+
return new UploadHttpError(status, msg, { cause: err, statusText })
|
|
79
199
|
}
|
|
80
200
|
|
|
81
|
-
return
|
|
201
|
+
return new UploadUnknownError(msg, { cause: err })
|
|
82
202
|
}
|
|
83
203
|
|
|
84
|
-
/**
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
204
|
+
/** Item has a concrete backend intent. */
|
|
205
|
+
export function hasIntent<
|
|
206
|
+
M extends Contracts.Intent.Map,
|
|
207
|
+
C extends Contracts.Cursor.Map<M>,
|
|
208
|
+
P extends string,
|
|
209
|
+
R extends Contracts.Result.Base,
|
|
210
|
+
>(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { intent: M[keyof M] }> {
|
|
90
211
|
return 'intent' in item && !!item.intent
|
|
91
212
|
}
|
|
92
213
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
): item is Extract<UploadItem<M, C, P, R>, { file: File }> {
|
|
214
|
+
/** Item has a bound `File`. Persisted items may lack it until the UI re-binds. */
|
|
215
|
+
export function hasFile<
|
|
216
|
+
M extends Contracts.Intent.Map,
|
|
217
|
+
C extends Contracts.Cursor.Map<M>,
|
|
218
|
+
P extends string,
|
|
219
|
+
R extends Contracts.Result.Base,
|
|
220
|
+
>(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { file: File }> {
|
|
101
221
|
return 'file' in item && !!item.file
|
|
102
222
|
}
|
|
103
223
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
): item is Extract<UploadItem<M, C, P, R>, { cursor?: AnyCursor<C> }> {
|
|
224
|
+
/** Item variant carries a cursor field (depends on phase/strategy). */
|
|
225
|
+
export function hasCursor<
|
|
226
|
+
M extends Contracts.Intent.Map,
|
|
227
|
+
C extends Contracts.Cursor.Map<M>,
|
|
228
|
+
P extends string,
|
|
229
|
+
R extends Contracts.Result.Base,
|
|
230
|
+
>(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { cursor?: Contracts.Cursor.Any<C> }> {
|
|
112
231
|
return 'cursor' in item
|
|
113
232
|
}
|
|
114
233
|
|
|
115
|
-
/**
|
|
116
|
-
* Runtime guard for multipart intent objects.
|
|
117
|
-
*
|
|
118
|
-
* Used for best-effort abort calls when canceling multipart uploads.
|
|
119
|
-
*/
|
|
234
|
+
/** Best-effort guard used to issue multipart-abort calls on cancel. */
|
|
120
235
|
export function isMultipartIntent(
|
|
121
236
|
intent: unknown,
|
|
122
237
|
): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
|
|
123
238
|
return (
|
|
124
239
|
isRecord(intent) &&
|
|
125
|
-
intent
|
|
126
|
-
typeof intent
|
|
127
|
-
typeof intent
|
|
240
|
+
intent['strategy'] === 'multipart' &&
|
|
241
|
+
typeof intent['fileId'] === 'string' &&
|
|
242
|
+
typeof intent['uploadId'] === 'string'
|
|
128
243
|
)
|
|
129
244
|
}
|
|
130
245
|
|
|
131
|
-
/**
|
|
132
|
-
* Runtime guard for abort errors produced by the transport/strategy layer.
|
|
133
|
-
*/
|
|
134
246
|
export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
|
|
135
|
-
return isRecord(err) && err
|
|
247
|
+
return isRecord(err) && err['code'] === 'aborted'
|
|
136
248
|
}
|
|
137
249
|
|
|
138
250
|
/**
|
|
139
|
-
*
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Computes whether a failed operation should be retried and, if so, after what delay.
|
|
144
|
-
*
|
|
145
|
-
* If {@link UploadConfig.retryPolicy} is provided it is used as the source of truth.
|
|
146
|
-
* Otherwise the default policy:
|
|
147
|
-
* - never retries auth/validation/strategy_missing/aborted
|
|
148
|
-
* - retries up to `maxAttempts` with exponential backoff and a fixed cap
|
|
251
|
+
* Decide whether to retry. Defers to `config.retryPolicy` when set.
|
|
252
|
+
* Default: never retries `auth`/`validation_failed`/`strategy_missing`/`aborted`;
|
|
253
|
+
* otherwise exponential backoff up to `maxAttempts`, capped at `DEFAULT_RETRY_DELAY_MAX_MS`.
|
|
149
254
|
*/
|
|
150
255
|
export function retryDecision<P extends string>(
|
|
151
|
-
config:
|
|
256
|
+
config: Engine.Config<P>,
|
|
152
257
|
ctx: { phase: 'intent' | 'upload' | 'complete'; attempt: number; error: UploadError },
|
|
153
258
|
) {
|
|
154
259
|
if (config.retryPolicy) return config.retryPolicy(ctx)
|
|
155
260
|
|
|
156
|
-
// Default: retry network/server unknown errors, not auth/validation/strategy_missing
|
|
157
261
|
if (ctx.error.code === 'auth') return { retryable: false }
|
|
158
262
|
if (ctx.error.code === 'validation_failed') return { retryable: false }
|
|
159
263
|
if (ctx.error.code === 'strategy_missing') return { retryable: false }
|
|
@@ -163,7 +267,6 @@ export function retryDecision<P extends string>(
|
|
|
163
267
|
const retryable = ctx.attempt < maxAttempts
|
|
164
268
|
if (!retryable) return { retryable: false }
|
|
165
269
|
|
|
166
|
-
// Exponential backoff with cap
|
|
167
270
|
const delayMs = Math.min(DEFAULT_RETRY_DELAY_MAX_MS, DEFAULT_RETRY_DELAY_BASE_MS * 2 ** (ctx.attempt - 1))
|
|
168
271
|
return { retryable: true, delayMs }
|
|
169
272
|
}
|
|
@@ -1,41 +1,31 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
3
2
|
import { createXHRTransport } from '../../contracts/transport'
|
|
4
3
|
import { deserializeSnapshot, serializeSnapshot } from '../../persistence'
|
|
5
4
|
import { createTypedEmitter } from '../../utils/emitter'
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
import type {
|
|
9
|
-
import { createReducer
|
|
5
|
+
import { isDevEnv } from '../../utils/guards'
|
|
6
|
+
import { resolveUploadConfig } from '../engine.libs'
|
|
7
|
+
import type { Engine } from '../engine.types'
|
|
8
|
+
import { createReducer } from '../reducer'
|
|
10
9
|
import { cleanupOldItems } from './handlers/clean-up'
|
|
11
10
|
import { scheduleWork } from './store.schedule'
|
|
12
|
-
import type {
|
|
11
|
+
import type { Store } from './store.types'
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* - Reducers remain pure: all async work is enqueued through {@link StoreRuntime.enqueueEffect}.
|
|
19
|
-
* - {@link StoreRuntime.applyInternal} invokes hooks, performs cleanup, and then schedules more work.
|
|
20
|
-
* - {@link StoreRuntime.dispatch} is intentionally assigned later to break import cycles.
|
|
21
|
-
*
|
|
22
|
-
* @template M - Intent map type
|
|
23
|
-
* @template C - Cursor map type
|
|
24
|
-
* @template P - Purpose string union type
|
|
25
|
-
*
|
|
26
|
-
* @param opts - Construction options for the store.
|
|
14
|
+
* Build the store runtime: state, emitter, reducer, and effect queue.
|
|
15
|
+
* Reducers stay pure async work goes through {@link StoreRuntime.enqueueEffect}.
|
|
16
|
+
* `dispatch` is assigned later by `createUploadStore` to break an import cycle.
|
|
27
17
|
*/
|
|
28
18
|
export function createStoreRuntime<
|
|
29
|
-
M extends
|
|
30
|
-
C extends
|
|
19
|
+
M extends Contracts.Intent.Map,
|
|
20
|
+
C extends Contracts.Cursor.Map<M>,
|
|
31
21
|
P extends string,
|
|
32
|
-
R extends
|
|
33
|
-
>(opts:
|
|
22
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
23
|
+
>(opts: Store.Options<M, C, P, R>): Store.Runtime<M, C, P, R> {
|
|
34
24
|
const resolvedOpts = resolveStoreOptions(opts)
|
|
35
25
|
const persistence = resolvedOpts.persistence
|
|
36
26
|
|
|
37
|
-
// Debounced
|
|
38
|
-
//
|
|
27
|
+
// Debounced flush. Persistence writes under a single namespace `key`; the
|
|
28
|
+
// adapter decides how to encode multiple item records under that key.
|
|
39
29
|
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
|
40
30
|
const persistDebounceMs = Math.max(0, persistence?.debounceMs ?? 200)
|
|
41
31
|
|
|
@@ -44,7 +34,7 @@ export function createStoreRuntime<
|
|
|
44
34
|
try {
|
|
45
35
|
const snap = (persistence.serialize ?? serializeSnapshot)(rt.state, persistence.version)
|
|
46
36
|
|
|
47
|
-
//
|
|
37
|
+
// Nothing to persist → clear the namespace.
|
|
48
38
|
if (!snap.items || Object.keys(snap.items).length === 0) {
|
|
49
39
|
await persistence.adapter.clear(persistence.key)
|
|
50
40
|
return
|
|
@@ -52,7 +42,7 @@ export function createStoreRuntime<
|
|
|
52
42
|
|
|
53
43
|
await persistence.adapter.save(persistence.key, snap)
|
|
54
44
|
} catch (err) {
|
|
55
|
-
if (
|
|
45
|
+
if (isDevEnv()) {
|
|
56
46
|
console.warn('[UploadEngine] persistence flush failed:', err)
|
|
57
47
|
}
|
|
58
48
|
}
|
|
@@ -68,21 +58,21 @@ export function createStoreRuntime<
|
|
|
68
58
|
persistTimer = setTimeout(() => void flushPersistence(), persistDebounceMs)
|
|
69
59
|
}
|
|
70
60
|
|
|
71
|
-
const initialState:
|
|
72
|
-
items: new Map<string,
|
|
61
|
+
const initialState: Engine.State<M, C, P, R> = resolvedOpts.initialState ?? {
|
|
62
|
+
items: new Map<string, Engine.Item<M, C, P, R>>(),
|
|
73
63
|
}
|
|
74
64
|
|
|
75
65
|
const effectQueue: Array<() => Promise<void>> = []
|
|
76
66
|
|
|
77
|
-
const rt:
|
|
67
|
+
const rt: Store.Runtime<M, C, P, R> = {
|
|
78
68
|
opts: resolvedOpts,
|
|
79
69
|
state: initialState,
|
|
80
70
|
|
|
81
71
|
listeners: new Set<() => void>(),
|
|
82
|
-
emitter: createTypedEmitter<
|
|
72
|
+
emitter: createTypedEmitter<Engine.EventMap<M, C, P, R>>(),
|
|
83
73
|
reduce: createReducer<M, C, P, R>(),
|
|
84
74
|
|
|
85
|
-
inflightUploads: new Map<string, InflightUpload>(),
|
|
75
|
+
inflightUploads: new Map<string, Store.InflightUpload>(),
|
|
86
76
|
inflightIntents: new Map<string, AbortController>(),
|
|
87
77
|
inflightCompletes: new Map<string, AbortController>(),
|
|
88
78
|
|
|
@@ -100,7 +90,7 @@ export function createStoreRuntime<
|
|
|
100
90
|
scheduleWork(rt)
|
|
101
91
|
},
|
|
102
92
|
|
|
103
|
-
applyInternal(event:
|
|
93
|
+
applyInternal(event: Engine.Event<M, C, P, R>) {
|
|
104
94
|
const prev = rt.state
|
|
105
95
|
const next = rt.reduce(prev, event)
|
|
106
96
|
rt.state = next
|
|
@@ -116,7 +106,7 @@ export function createStoreRuntime<
|
|
|
116
106
|
rt.scheduleWork()
|
|
117
107
|
},
|
|
118
108
|
|
|
119
|
-
applyCommand(cmd:
|
|
109
|
+
applyCommand(cmd: Engine.Command<P>) {
|
|
120
110
|
const prev = rt.state
|
|
121
111
|
const next = rt.reduce(prev, cmd)
|
|
122
112
|
rt.state = next
|
|
@@ -144,7 +134,7 @@ export function createStoreRuntime<
|
|
|
144
134
|
try {
|
|
145
135
|
await effect()
|
|
146
136
|
} catch (err) {
|
|
147
|
-
if (
|
|
137
|
+
if (isDevEnv()) {
|
|
148
138
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
149
139
|
const context = {
|
|
150
140
|
queueLength: rt.effectQueue.length,
|
|
@@ -158,14 +148,15 @@ export function createStoreRuntime<
|
|
|
158
148
|
rt.processingEffects = false
|
|
159
149
|
},
|
|
160
150
|
|
|
161
|
-
//
|
|
151
|
+
// Replaced by createUploadStore runtime breaks the cycle.
|
|
162
152
|
dispatch: () => {
|
|
163
153
|
throw new Error('[UploadEngine] dispatch not initialized')
|
|
164
154
|
},
|
|
165
|
-
} satisfies
|
|
155
|
+
} satisfies Store.Runtime<M, C, P, R>
|
|
166
156
|
|
|
167
|
-
//
|
|
168
|
-
//
|
|
157
|
+
// Async adapters (IndexedDB) resolve after construction; only hydrate when no
|
|
158
|
+
// initialState was provided. Merge into an empty store only never clobber
|
|
159
|
+
// user actions that arrived before the load resolved.
|
|
169
160
|
if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
|
|
170
161
|
try {
|
|
171
162
|
const loaded = persistence.adapter.load(persistence.key)
|
|
@@ -178,8 +169,6 @@ export function createStoreRuntime<
|
|
|
178
169
|
})
|
|
179
170
|
if (!next) return
|
|
180
171
|
|
|
181
|
-
// Only merge into an empty store to avoid clobbering user actions
|
|
182
|
-
// that happened before the async load completed.
|
|
183
172
|
if (rt.state.items.size === 0) {
|
|
184
173
|
rt.state = next
|
|
185
174
|
} else {
|
|
@@ -205,9 +194,12 @@ export function createStoreRuntime<
|
|
|
205
194
|
return rt
|
|
206
195
|
}
|
|
207
196
|
|
|
208
|
-
function resolveStoreOptions<
|
|
209
|
-
|
|
210
|
-
|
|
197
|
+
function resolveStoreOptions<
|
|
198
|
+
M extends Contracts.Intent.Map,
|
|
199
|
+
C extends Contracts.Cursor.Map<M>,
|
|
200
|
+
P extends string,
|
|
201
|
+
R extends Contracts.Result.Base,
|
|
202
|
+
>(opts: Store.Options<M, C, P, R>): Store.Runtime<M, C, P, R>['opts'] {
|
|
211
203
|
return {
|
|
212
204
|
...opts,
|
|
213
205
|
config: resolveUploadConfig(opts.config),
|
|
@@ -215,10 +207,12 @@ function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P exte
|
|
|
215
207
|
}
|
|
216
208
|
}
|
|
217
209
|
|
|
218
|
-
function emitInternalEvent<
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
210
|
+
function emitInternalEvent<
|
|
211
|
+
M extends Contracts.Intent.Map,
|
|
212
|
+
C extends Contracts.Cursor.Map<M>,
|
|
213
|
+
P extends string,
|
|
214
|
+
R extends Contracts.Result.Base,
|
|
215
|
+
>(rt: Store.Runtime<M, C, P, R>, event: Engine.Event<M, C, P, R>) {
|
|
222
216
|
switch (event.type) {
|
|
223
217
|
case 'files.added': {
|
|
224
218
|
for (const item of event.items) {
|
|
@@ -308,11 +302,16 @@ function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extend
|
|
|
308
302
|
}
|
|
309
303
|
}
|
|
310
304
|
|
|
311
|
-
function emitCommandEvents<
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
305
|
+
function emitCommandEvents<
|
|
306
|
+
M extends Contracts.Intent.Map,
|
|
307
|
+
C extends Contracts.Cursor.Map<M>,
|
|
308
|
+
P extends string,
|
|
309
|
+
R extends Contracts.Result.Base,
|
|
310
|
+
>(
|
|
311
|
+
rt: Store.Runtime<M, C, P, R>,
|
|
312
|
+
cmd: Engine.Command<P>,
|
|
313
|
+
prev: Engine.State<M, C, P, R>,
|
|
314
|
+
next: Engine.State<M, C, P, R>,
|
|
316
315
|
) {
|
|
317
316
|
if (cmd.type === 'start' || cmd.type === 'resume') {
|
|
318
317
|
const prevItem = prev.items.get(cmd.localId)
|