@gentleduck/upload 0.2.6 → 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 +170 -193
- 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 -51
- 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/{__test__ → __tests__}/emitter.test.ts +4 -4
- package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
- package/src/{__test__ → __tests__}/guards.test.ts +15 -5
- package/src/__tests__/id.test.ts +67 -0
- package/src/__tests__/mime-sniff.test.ts +142 -0
- package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
- 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/{__test__ → __tests__}/store-libs.test.ts +57 -3
- package/src/__tests__/transport-fetch.test.ts +108 -0
- package/src/{__test__ → __tests__}/transport.test.ts +5 -5
- package/src/core/__tests__/errors.test.ts +152 -0
- package/src/core/client.ts +3 -0
- package/src/core/contracts/contracts.types.ts +335 -141
- 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 +7 -8
- package/src/core/contracts/transport/transport.ts +91 -83
- 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 +367 -241
- package/src/core/engine/index.ts +3 -8
- package/src/core/engine/reducer.ts +89 -43
- package/src/core/engine/store/handlers/add-file.ts +39 -29
- 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 +49 -34
- 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 +184 -41
- package/src/core/engine/store/store.runtime.ts +45 -36
- 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 +7 -3
- 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 +7 -24
- 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 +68 -53
- package/src/react/uploader.types.ts +58 -61
- package/src/react/use-dropzone.tsx +226 -0
- package/src/react/use-uploader.tsx +98 -91
- 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 +206 -108
- package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
- package/src/strategies/post/index.ts +115 -33
- 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-CMkrP9DX.js +0 -2
- package/dist/index-C38Jmizu.d.ts +0 -1005
- package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
- package/src/__test__/effect-pool.test.ts +0 -5
- package/src/__test__/id.test.ts +0 -14
- package/src/__test__/store-integration.test.ts +0 -5
- package/src/core/client/client.ts +0 -50
- package/src/core/client/client.types.ts +0 -184
- 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 -202
- package/src/core/engine/outcome.types.ts +0 -9
- package/src/core/engine/progress.types.ts +0 -15
- package/src/core/engine/store/handlers.ts +0 -819
- package/src/core/engine/validation.ts +0 -216
- /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
|
@@ -1,34 +1,86 @@
|
|
|
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
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Default boundary between inline and incremental hashing when
|
|
37
|
+
* `config.checksumMaxSize` is unset (`null`) or `0`: 64 MiB.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_CHECKSUM_MAX_SIZE = 64 * 1024 * 1024
|
|
11
40
|
|
|
12
|
-
/**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
41
|
+
/**
|
|
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.
|
|
49
|
+
*
|
|
50
|
+
* Pass `maxSize = 0` or omit it to use {@link DEFAULT_CHECKSUM_MAX_SIZE}.
|
|
51
|
+
*
|
|
52
|
+
* @returns lowercase hex SHA-256 digest.
|
|
53
|
+
*/
|
|
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 })
|
|
18
60
|
}
|
|
19
61
|
|
|
20
62
|
/**
|
|
21
63
|
* Resolve auto-start config: `false | undefined` (never), `P[]` (in list), or
|
|
22
64
|
* `(purpose) => boolean` predicate.
|
|
23
65
|
*/
|
|
24
|
-
export function isAutoStart<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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 {
|
|
28
72
|
const v = opts.config?.autoStart
|
|
29
73
|
if (v === undefined) return false
|
|
30
74
|
if (Array.isArray(v)) return v.includes(purpose)
|
|
31
|
-
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
|
+
}
|
|
32
84
|
return false
|
|
33
85
|
}
|
|
34
86
|
|
|
@@ -40,51 +92,142 @@ export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P exten
|
|
|
40
92
|
export { computeFingerprint, fingerprintMatches }
|
|
41
93
|
|
|
42
94
|
/**
|
|
43
|
-
*
|
|
95
|
+
* When `findByChecksum` matches, complete the item without creating an intent or
|
|
96
|
+
* uploading bytes. Returns `true` when dedupe succeeded.
|
|
97
|
+
*/
|
|
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
|
|
44
173
|
* {@link StoreOptions.errorNormalizer}. Default: aborts → non-retryable;
|
|
45
174
|
* network errors → retryable; HTTP 5xx/429 → retryable.
|
|
46
175
|
*/
|
|
47
|
-
export function normalizeError(err: unknown, customNormalizer?: (err: unknown) =>
|
|
176
|
+
export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => Contracts.Errors.Error): UploadError {
|
|
48
177
|
if (customNormalizer) return customNormalizer(err)
|
|
49
178
|
|
|
179
|
+
if (err instanceof UploadEngineError) {
|
|
180
|
+
return err
|
|
181
|
+
}
|
|
182
|
+
|
|
50
183
|
// Transport aborts carry `{ code: 'aborted', reason: 'pause' | 'cancel' }`.
|
|
51
184
|
if (isAbortError(err)) {
|
|
52
|
-
|
|
185
|
+
const reason = err.reason === 'pause' || err.reason === 'cancel' ? err.reason : 'unknown'
|
|
186
|
+
return new UploadAbortError(reason, undefined, { cause: err })
|
|
53
187
|
}
|
|
54
188
|
|
|
55
|
-
const msg = isRecord(err) && typeof err
|
|
189
|
+
const msg = isRecord(err) && typeof err['message'] === 'string' ? (err['message'] as string) : 'Unknown error'
|
|
56
190
|
|
|
57
|
-
if (
|
|
58
|
-
return
|
|
191
|
+
if (msg.toLowerCase().includes('network') || msg.toLowerCase().includes('fetch')) {
|
|
192
|
+
return new UploadNetworkError(msg, { cause: err })
|
|
59
193
|
}
|
|
60
194
|
|
|
61
|
-
if (isRecord(err) && (typeof err
|
|
62
|
-
const status = (typeof err
|
|
63
|
-
const
|
|
64
|
-
return
|
|
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 })
|
|
65
199
|
}
|
|
66
200
|
|
|
67
|
-
return
|
|
201
|
+
return new UploadUnknownError(msg, { cause: err })
|
|
68
202
|
}
|
|
69
203
|
|
|
70
204
|
/** Item has a concrete backend intent. */
|
|
71
|
-
export function hasIntent<
|
|
72
|
-
|
|
73
|
-
|
|
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] }> {
|
|
74
211
|
return 'intent' in item && !!item.intent
|
|
75
212
|
}
|
|
76
213
|
|
|
77
214
|
/** Item has a bound `File`. Persisted items may lack it until the UI re-binds. */
|
|
78
|
-
export function hasFile<
|
|
79
|
-
|
|
80
|
-
|
|
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 }> {
|
|
81
221
|
return 'file' in item && !!item.file
|
|
82
222
|
}
|
|
83
223
|
|
|
84
224
|
/** Item variant carries a cursor field (depends on phase/strategy). */
|
|
85
|
-
export function hasCursor<
|
|
86
|
-
|
|
87
|
-
|
|
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> }> {
|
|
88
231
|
return 'cursor' in item
|
|
89
232
|
}
|
|
90
233
|
|
|
@@ -94,14 +237,14 @@ export function isMultipartIntent(
|
|
|
94
237
|
): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
|
|
95
238
|
return (
|
|
96
239
|
isRecord(intent) &&
|
|
97
|
-
intent
|
|
98
|
-
typeof intent
|
|
99
|
-
typeof intent
|
|
240
|
+
intent['strategy'] === 'multipart' &&
|
|
241
|
+
typeof intent['fileId'] === 'string' &&
|
|
242
|
+
typeof intent['uploadId'] === 'string'
|
|
100
243
|
)
|
|
101
244
|
}
|
|
102
245
|
|
|
103
246
|
export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
|
|
104
|
-
return isRecord(err) && err
|
|
247
|
+
return isRecord(err) && err['code'] === 'aborted'
|
|
105
248
|
}
|
|
106
249
|
|
|
107
250
|
/**
|
|
@@ -110,7 +253,7 @@ export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: u
|
|
|
110
253
|
* otherwise exponential backoff up to `maxAttempts`, capped at `DEFAULT_RETRY_DELAY_MAX_MS`.
|
|
111
254
|
*/
|
|
112
255
|
export function retryDecision<P extends string>(
|
|
113
|
-
config:
|
|
256
|
+
config: Engine.Config<P>,
|
|
114
257
|
ctx: { phase: 'intent' | 'upload' | 'complete'; attempt: number; error: UploadError },
|
|
115
258
|
) {
|
|
116
259
|
if (config.retryPolicy) return config.retryPolicy(ctx)
|
|
@@ -1,27 +1,26 @@
|
|
|
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
14
|
* Build the store runtime: state, emitter, reducer, and effect queue.
|
|
16
|
-
* Reducers stay pure
|
|
15
|
+
* Reducers stay pure async work goes through {@link StoreRuntime.enqueueEffect}.
|
|
17
16
|
* `dispatch` is assigned later by `createUploadStore` to break an import cycle.
|
|
18
17
|
*/
|
|
19
18
|
export function createStoreRuntime<
|
|
20
|
-
M extends
|
|
21
|
-
C extends
|
|
19
|
+
M extends Contracts.Intent.Map,
|
|
20
|
+
C extends Contracts.Cursor.Map<M>,
|
|
22
21
|
P extends string,
|
|
23
|
-
R extends
|
|
24
|
-
>(opts:
|
|
22
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
23
|
+
>(opts: Store.Options<M, C, P, R>): Store.Runtime<M, C, P, R> {
|
|
25
24
|
const resolvedOpts = resolveStoreOptions(opts)
|
|
26
25
|
const persistence = resolvedOpts.persistence
|
|
27
26
|
|
|
@@ -43,7 +42,7 @@ export function createStoreRuntime<
|
|
|
43
42
|
|
|
44
43
|
await persistence.adapter.save(persistence.key, snap)
|
|
45
44
|
} catch (err) {
|
|
46
|
-
if (
|
|
45
|
+
if (isDevEnv()) {
|
|
47
46
|
console.warn('[UploadEngine] persistence flush failed:', err)
|
|
48
47
|
}
|
|
49
48
|
}
|
|
@@ -59,21 +58,21 @@ export function createStoreRuntime<
|
|
|
59
58
|
persistTimer = setTimeout(() => void flushPersistence(), persistDebounceMs)
|
|
60
59
|
}
|
|
61
60
|
|
|
62
|
-
const initialState:
|
|
63
|
-
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>>(),
|
|
64
63
|
}
|
|
65
64
|
|
|
66
65
|
const effectQueue: Array<() => Promise<void>> = []
|
|
67
66
|
|
|
68
|
-
const rt:
|
|
67
|
+
const rt: Store.Runtime<M, C, P, R> = {
|
|
69
68
|
opts: resolvedOpts,
|
|
70
69
|
state: initialState,
|
|
71
70
|
|
|
72
71
|
listeners: new Set<() => void>(),
|
|
73
|
-
emitter: createTypedEmitter<
|
|
72
|
+
emitter: createTypedEmitter<Engine.EventMap<M, C, P, R>>(),
|
|
74
73
|
reduce: createReducer<M, C, P, R>(),
|
|
75
74
|
|
|
76
|
-
inflightUploads: new Map<string, InflightUpload>(),
|
|
75
|
+
inflightUploads: new Map<string, Store.InflightUpload>(),
|
|
77
76
|
inflightIntents: new Map<string, AbortController>(),
|
|
78
77
|
inflightCompletes: new Map<string, AbortController>(),
|
|
79
78
|
|
|
@@ -91,7 +90,7 @@ export function createStoreRuntime<
|
|
|
91
90
|
scheduleWork(rt)
|
|
92
91
|
},
|
|
93
92
|
|
|
94
|
-
applyInternal(event:
|
|
93
|
+
applyInternal(event: Engine.Event<M, C, P, R>) {
|
|
95
94
|
const prev = rt.state
|
|
96
95
|
const next = rt.reduce(prev, event)
|
|
97
96
|
rt.state = next
|
|
@@ -107,7 +106,7 @@ export function createStoreRuntime<
|
|
|
107
106
|
rt.scheduleWork()
|
|
108
107
|
},
|
|
109
108
|
|
|
110
|
-
applyCommand(cmd:
|
|
109
|
+
applyCommand(cmd: Engine.Command<P>) {
|
|
111
110
|
const prev = rt.state
|
|
112
111
|
const next = rt.reduce(prev, cmd)
|
|
113
112
|
rt.state = next
|
|
@@ -135,7 +134,7 @@ export function createStoreRuntime<
|
|
|
135
134
|
try {
|
|
136
135
|
await effect()
|
|
137
136
|
} catch (err) {
|
|
138
|
-
if (
|
|
137
|
+
if (isDevEnv()) {
|
|
139
138
|
const error = err instanceof Error ? err : new Error(String(err))
|
|
140
139
|
const context = {
|
|
141
140
|
queueLength: rt.effectQueue.length,
|
|
@@ -149,14 +148,14 @@ export function createStoreRuntime<
|
|
|
149
148
|
rt.processingEffects = false
|
|
150
149
|
},
|
|
151
150
|
|
|
152
|
-
// Replaced by createUploadStore
|
|
151
|
+
// Replaced by createUploadStore runtime breaks the cycle.
|
|
153
152
|
dispatch: () => {
|
|
154
153
|
throw new Error('[UploadEngine] dispatch not initialized')
|
|
155
154
|
},
|
|
156
|
-
} satisfies
|
|
155
|
+
} satisfies Store.Runtime<M, C, P, R>
|
|
157
156
|
|
|
158
157
|
// Async adapters (IndexedDB) resolve after construction; only hydrate when no
|
|
159
|
-
// initialState was provided. Merge into an empty store only
|
|
158
|
+
// initialState was provided. Merge into an empty store only never clobber
|
|
160
159
|
// user actions that arrived before the load resolved.
|
|
161
160
|
if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
|
|
162
161
|
try {
|
|
@@ -195,9 +194,12 @@ export function createStoreRuntime<
|
|
|
195
194
|
return rt
|
|
196
195
|
}
|
|
197
196
|
|
|
198
|
-
function resolveStoreOptions<
|
|
199
|
-
|
|
200
|
-
|
|
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'] {
|
|
201
203
|
return {
|
|
202
204
|
...opts,
|
|
203
205
|
config: resolveUploadConfig(opts.config),
|
|
@@ -205,10 +207,12 @@ function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P exte
|
|
|
205
207
|
}
|
|
206
208
|
}
|
|
207
209
|
|
|
208
|
-
function emitInternalEvent<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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>) {
|
|
212
216
|
switch (event.type) {
|
|
213
217
|
case 'files.added': {
|
|
214
218
|
for (const item of event.items) {
|
|
@@ -298,11 +302,16 @@ function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extend
|
|
|
298
302
|
}
|
|
299
303
|
}
|
|
300
304
|
|
|
301
|
-
function emitCommandEvents<
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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>,
|
|
306
315
|
) {
|
|
307
316
|
if (cmd.type === 'start' || cmd.type === 'resume') {
|
|
308
317
|
const prevItem = prev.items.get(cmd.localId)
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import type { Engine } from '../engine.types'
|
|
3
3
|
import { createIntent } from './handlers/create-intent'
|
|
4
4
|
import { finalizeUpload } from './handlers/finalize'
|
|
5
5
|
import { runUpload } from './handlers/run-upload'
|
|
6
6
|
import { isAutoStart } from './store.libs'
|
|
7
|
-
import type {
|
|
7
|
+
import type { Store } from './store.types'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Central scheduler that ensures the system is always making progress towards a consistent state.
|
|
@@ -15,9 +15,12 @@ import type { StoreRuntime } from './store.types'
|
|
|
15
15
|
*
|
|
16
16
|
* This function is idempotent and safe to call multiple times.
|
|
17
17
|
*/
|
|
18
|
-
export function scheduleWork<
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
export function scheduleWork<
|
|
19
|
+
M extends Contracts.Intent.Map,
|
|
20
|
+
C extends Contracts.Cursor.Map<M>,
|
|
21
|
+
P extends string,
|
|
22
|
+
R extends Contracts.Result.Base,
|
|
23
|
+
>(rt: Store.Runtime<M, C, P, R>) {
|
|
21
24
|
if (rt.scheduling) return
|
|
22
25
|
rt.scheduling = true
|
|
23
26
|
try {
|
|
@@ -36,11 +39,11 @@ export function scheduleWork<M extends IntentMap, C extends CursorMap<M>, P exte
|
|
|
36
39
|
* {@link StoreRuntime.inflightIntents}.
|
|
37
40
|
*/
|
|
38
41
|
export function scheduleIntentCreations<
|
|
39
|
-
M extends
|
|
40
|
-
C extends
|
|
42
|
+
M extends Contracts.Intent.Map,
|
|
43
|
+
C extends Contracts.Cursor.Map<M>,
|
|
41
44
|
P extends string,
|
|
42
|
-
R extends
|
|
43
|
-
>(rt:
|
|
45
|
+
R extends Contracts.Result.Base,
|
|
46
|
+
>(rt: Store.Runtime<M, C, P, R>) {
|
|
44
47
|
for (const item of rt.state.items.values()) {
|
|
45
48
|
if (item.phase !== 'creating_intent') continue
|
|
46
49
|
if (rt.inflightIntents.has(item.localId)) continue
|
|
@@ -54,11 +57,11 @@ export function scheduleIntentCreations<
|
|
|
54
57
|
* Finalization runs as an async effect and is guarded by {@link StoreRuntime.inflightCompletes}.
|
|
55
58
|
*/
|
|
56
59
|
export function scheduleCompletes<
|
|
57
|
-
M extends
|
|
58
|
-
C extends
|
|
60
|
+
M extends Contracts.Intent.Map,
|
|
61
|
+
C extends Contracts.Cursor.Map<M>,
|
|
59
62
|
P extends string,
|
|
60
|
-
R extends
|
|
61
|
-
>(rt:
|
|
63
|
+
R extends Contracts.Result.Base,
|
|
64
|
+
>(rt: Store.Runtime<M, C, P, R>) {
|
|
62
65
|
for (const item of rt.state.items.values()) {
|
|
63
66
|
if (item.phase !== 'completing') continue
|
|
64
67
|
if (rt.inflightCompletes.has(item.localId)) continue
|
|
@@ -71,11 +74,11 @@ export function scheduleCompletes<
|
|
|
71
74
|
* Respects `maxConcurrentUploads` from config.
|
|
72
75
|
*/
|
|
73
76
|
export function scheduleUploads<
|
|
74
|
-
M extends
|
|
75
|
-
C extends
|
|
77
|
+
M extends Contracts.Intent.Map,
|
|
78
|
+
C extends Contracts.Cursor.Map<M>,
|
|
76
79
|
P extends string,
|
|
77
|
-
R extends
|
|
78
|
-
>(rt:
|
|
80
|
+
R extends Contracts.Result.Base,
|
|
81
|
+
>(rt: Store.Runtime<M, C, P, R>) {
|
|
79
82
|
const maxConcurrent = Math.max(1, rt.opts.config.maxConcurrentUploads)
|
|
80
83
|
const active = rt.inflightUploads.size
|
|
81
84
|
|
|
@@ -109,8 +112,11 @@ export function scheduleUploads<
|
|
|
109
112
|
}
|
|
110
113
|
}
|
|
111
114
|
|
|
112
|
-
function isQueuedItem<
|
|
113
|
-
|
|
114
|
-
|
|
115
|
+
function isQueuedItem<
|
|
116
|
+
M extends Contracts.Intent.Map,
|
|
117
|
+
C extends Contracts.Cursor.Map<M>,
|
|
118
|
+
P extends string,
|
|
119
|
+
R extends Contracts.Result.Base,
|
|
120
|
+
>(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { phase: 'queued' }> {
|
|
115
121
|
return item.phase === 'queued'
|
|
116
122
|
}
|