@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,53 +1,102 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import type { UploadError } from '../../../errors'
|
|
3
|
+
import { UploadValidationError } from '../../../errors'
|
|
4
|
+
import { isRecord } from '../../../utils/guards'
|
|
5
|
+
import { sanitizeFilename } from '../../../utils/sanitize-filename'
|
|
2
6
|
import { validateIntent } from '../../validation'
|
|
3
|
-
import {
|
|
4
|
-
|
|
7
|
+
import {
|
|
8
|
+
buildApiContext,
|
|
9
|
+
normalizeError,
|
|
10
|
+
parseCreateIntentDedupeResult,
|
|
11
|
+
retryDecision,
|
|
12
|
+
sleep,
|
|
13
|
+
tryDedupeByChecksum,
|
|
14
|
+
} from '../store.libs'
|
|
15
|
+
import type { Store } from '../store.types'
|
|
5
16
|
|
|
6
17
|
export async function createIntent<
|
|
7
|
-
M extends
|
|
8
|
-
C extends
|
|
18
|
+
M extends Contracts.Intent.Map,
|
|
19
|
+
C extends Contracts.Cursor.Map<M>,
|
|
9
20
|
P extends string,
|
|
10
|
-
R extends
|
|
11
|
-
>(rt:
|
|
21
|
+
R extends Contracts.Result.Base,
|
|
22
|
+
>(rt: Store.Runtime<M, C, P, R>, localId: string) {
|
|
12
23
|
const item = rt.state.items.get(localId)
|
|
13
|
-
if (
|
|
24
|
+
if (item?.phase !== 'creating_intent') return
|
|
14
25
|
if (rt.inflightIntents.has(localId)) return
|
|
15
26
|
|
|
27
|
+
const sanitised = sanitizeFilename(item.file.name)
|
|
28
|
+
if (!sanitised.safe) {
|
|
29
|
+
const error = new UploadValidationError(
|
|
30
|
+
{ code: 'filename_rejected', reason: sanitised.reason },
|
|
31
|
+
'filename rejected',
|
|
32
|
+
{ context: { original: item.file.name, reason: sanitised.reason } },
|
|
33
|
+
)
|
|
34
|
+
rt.applyInternal({
|
|
35
|
+
type: 'intent.failed',
|
|
36
|
+
localId,
|
|
37
|
+
error,
|
|
38
|
+
retryable: false,
|
|
39
|
+
})
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
16
43
|
const controller = new AbortController()
|
|
17
44
|
rt.inflightIntents.set(localId, controller)
|
|
18
45
|
|
|
19
46
|
try {
|
|
47
|
+
if (await tryDedupeByChecksum(rt, localId, item.fingerprint.checksum, item.purpose, ['creating_intent'])) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const intentArgs: {
|
|
52
|
+
purpose: P
|
|
53
|
+
contentType: string
|
|
54
|
+
size: number
|
|
55
|
+
filename: string
|
|
56
|
+
checksum?: string
|
|
57
|
+
attempt: number
|
|
58
|
+
} = {
|
|
59
|
+
purpose: item.purpose,
|
|
60
|
+
contentType: item.file.type || 'application/octet-stream',
|
|
61
|
+
size: item.file.size,
|
|
62
|
+
filename: sanitised.normalised,
|
|
63
|
+
attempt: item.attempt ?? 1,
|
|
64
|
+
}
|
|
65
|
+
if (item.fingerprint.checksum !== undefined) {
|
|
66
|
+
intentArgs.checksum = item.fingerprint.checksum
|
|
67
|
+
}
|
|
68
|
+
|
|
20
69
|
const intent = await rt.opts.api.createIntent(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
contentType: item.file.type || 'application/octet-stream',
|
|
24
|
-
size: item.file.size,
|
|
25
|
-
filename: item.file.name,
|
|
26
|
-
checksum: item.fingerprint.checksum,
|
|
27
|
-
},
|
|
28
|
-
{ signal: controller.signal },
|
|
70
|
+
intentArgs,
|
|
71
|
+
buildApiContext(item, { signal: controller.signal }) as Contracts.Api.CreateIntentContext<P, M>,
|
|
29
72
|
)
|
|
30
73
|
|
|
31
74
|
rt.inflightIntents.delete(localId)
|
|
32
75
|
|
|
33
76
|
// Item might have been canceled while intent was creating
|
|
34
77
|
const current = rt.state.items.get(localId)
|
|
35
|
-
if (
|
|
78
|
+
if (current?.phase !== 'creating_intent') return
|
|
79
|
+
|
|
80
|
+
const dedupeResult = parseCreateIntentDedupeResult<R>(intent)
|
|
81
|
+
if (dedupeResult) {
|
|
82
|
+
rt.applyInternal({ type: 'dedupe.ok', localId, result: dedupeResult })
|
|
83
|
+
return
|
|
84
|
+
}
|
|
36
85
|
|
|
37
86
|
// Validate intent from backend
|
|
38
|
-
const
|
|
87
|
+
const strategy = isRecord(intent) && typeof intent.strategy === 'string' ? intent.strategy : ''
|
|
88
|
+
const intentError = validateIntent(intent, strategy)
|
|
39
89
|
if (intentError) {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
90
|
+
rt.applyInternal({
|
|
91
|
+
type: 'intent.failed',
|
|
92
|
+
localId,
|
|
93
|
+
error: intentError,
|
|
44
94
|
retryable: false,
|
|
45
|
-
}
|
|
46
|
-
rt.applyInternal({ type: 'intent.failed', localId, error, retryable: false })
|
|
95
|
+
})
|
|
47
96
|
return
|
|
48
97
|
}
|
|
49
98
|
|
|
50
|
-
rt.applyInternal({ type: 'intent.ok', localId, intent: intent as
|
|
99
|
+
rt.applyInternal({ type: 'intent.ok', localId, intent: intent as Contracts.Intent.Any<M> })
|
|
51
100
|
} catch (err: unknown) {
|
|
52
101
|
rt.inflightIntents.delete(localId)
|
|
53
102
|
|
|
@@ -59,10 +108,16 @@ export async function createIntent<
|
|
|
59
108
|
const error = normalizeError(err, rt.opts.errorNormalizer)
|
|
60
109
|
const errorWithContext: UploadError = {
|
|
61
110
|
...error,
|
|
62
|
-
|
|
111
|
+
context: {
|
|
112
|
+
...((error as { context?: Record<string, unknown> }).context ?? {}),
|
|
113
|
+
filename: item.fingerprint.name,
|
|
114
|
+
size: item.file.size,
|
|
115
|
+
purpose: item.purpose,
|
|
116
|
+
},
|
|
63
117
|
}
|
|
64
118
|
|
|
65
|
-
const
|
|
119
|
+
const attempt = item.attempt ?? 1
|
|
120
|
+
const decision = retryDecision(rt.opts.config, { phase: 'intent', attempt, error: errorWithContext })
|
|
66
121
|
|
|
67
122
|
rt.applyInternal({ type: 'intent.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
68
123
|
|
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import type { UploadError } from '../../../errors'
|
|
3
|
+
import { buildApiContext, hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
|
|
4
|
+
import type { Store } from '../store.types'
|
|
5
|
+
|
|
6
|
+
function hasContext(e: UploadError): e is UploadError & { context: Record<string, unknown> } {
|
|
7
|
+
return typeof e === 'object' && e !== null && 'context' in e && typeof e.context === 'object' && e.context !== null
|
|
8
|
+
}
|
|
4
9
|
|
|
5
10
|
export async function finalizeUpload<
|
|
6
|
-
M extends
|
|
7
|
-
C extends
|
|
11
|
+
M extends Contracts.Intent.Map,
|
|
12
|
+
C extends Contracts.Cursor.Map<M>,
|
|
8
13
|
P extends string,
|
|
9
|
-
R extends
|
|
10
|
-
>(rt:
|
|
14
|
+
R extends Contracts.Result.Base,
|
|
15
|
+
>(rt: Store.Runtime<M, C, P, R>, localId: string) {
|
|
11
16
|
const item = rt.state.items.get(localId)
|
|
12
|
-
if (
|
|
17
|
+
if (item?.phase !== 'completing') return
|
|
13
18
|
if (rt.inflightCompletes.has(localId)) return
|
|
14
19
|
|
|
15
20
|
const controller = new AbortController()
|
|
@@ -18,12 +23,22 @@ export async function finalizeUpload<
|
|
|
18
23
|
try {
|
|
19
24
|
// Final commit step (backend should verify upload, then mark/commit it).
|
|
20
25
|
// Strategy-specific API calls (multipart/tus) must happen inside the strategy itself.
|
|
21
|
-
const result = await rt.opts.api.complete(
|
|
26
|
+
const result = await rt.opts.api.complete(
|
|
27
|
+
{
|
|
28
|
+
fileId: item.intent.fileId,
|
|
29
|
+
filename: item.fingerprint.name,
|
|
30
|
+
contentType: item.file.type || 'application/octet-stream',
|
|
31
|
+
size: item.fingerprint.size,
|
|
32
|
+
...(item.fingerprint.checksum !== undefined ? { checksum: item.fingerprint.checksum } : {}),
|
|
33
|
+
attempt: item.attempt ?? 1,
|
|
34
|
+
},
|
|
35
|
+
buildApiContext(item, { signal: controller.signal }) as Contracts.Api.CompleteContext<P, M>,
|
|
36
|
+
)
|
|
22
37
|
rt.inflightCompletes.delete(localId)
|
|
23
38
|
|
|
24
39
|
// Item might have been canceled
|
|
25
40
|
const current = rt.state.items.get(localId)
|
|
26
|
-
if (
|
|
41
|
+
if (current?.phase !== 'completing') return
|
|
27
42
|
|
|
28
43
|
rt.applyInternal({ type: 'complete.ok', localId, result })
|
|
29
44
|
} catch (err: unknown) {
|
|
@@ -37,9 +52,11 @@ export async function finalizeUpload<
|
|
|
37
52
|
const errorWithContext: UploadError = itemForContext
|
|
38
53
|
? {
|
|
39
54
|
...error,
|
|
40
|
-
|
|
41
|
-
(
|
|
42
|
-
|
|
55
|
+
context: {
|
|
56
|
+
...(hasContext(error) ? error.context : {}),
|
|
57
|
+
filename: itemForContext.fingerprint.name,
|
|
58
|
+
fileId: (hasIntent(itemForContext) && itemForContext.intent?.fileId) ?? 'unknown',
|
|
59
|
+
},
|
|
43
60
|
}
|
|
44
61
|
: error
|
|
45
62
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export
|
|
2
|
-
export
|
|
3
|
-
export
|
|
4
|
-
export
|
|
5
|
-
export
|
|
6
|
-
export
|
|
7
|
-
export
|
|
1
|
+
export { handleAddFiles } from './add-file'
|
|
2
|
+
export { handleCancel } from './cancel'
|
|
3
|
+
export { cleanupOldItems } from './clean-up'
|
|
4
|
+
export { createIntent } from './create-intent'
|
|
5
|
+
export { finalizeUpload } from './finalize'
|
|
6
|
+
export { handlePause } from './pause'
|
|
7
|
+
export { runUpload } from './run-upload'
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import type { Store } from '../store.types'
|
|
3
3
|
|
|
4
|
-
export function handlePause<
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export function handlePause<
|
|
5
|
+
M extends Contracts.Intent.Map,
|
|
6
|
+
C extends Contracts.Cursor.Map<M>,
|
|
7
|
+
P extends string,
|
|
8
|
+
R extends Contracts.Result.Base,
|
|
9
|
+
>(rt: Store.Runtime<M, C, P, R>, localId: string) {
|
|
8
10
|
const inflight = rt.inflightUploads.get(localId)
|
|
9
11
|
if (inflight) {
|
|
10
12
|
inflight.mode = 'pause'
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../../contracts'
|
|
2
|
+
import type { UploadError } from '../../../errors'
|
|
3
|
+
import { UploadStrategyMissingError } from '../../../errors'
|
|
2
4
|
import { hasCursor, hasFile, hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
|
|
3
|
-
import type {
|
|
5
|
+
import type { Store } from '../store.types'
|
|
4
6
|
|
|
5
7
|
export async function runUpload<
|
|
6
|
-
M extends
|
|
7
|
-
C extends
|
|
8
|
+
M extends Contracts.Intent.Map,
|
|
9
|
+
C extends Contracts.Cursor.Map<M>,
|
|
8
10
|
P extends string,
|
|
9
|
-
R extends
|
|
10
|
-
>(rt:
|
|
11
|
+
R extends Contracts.Result.Base,
|
|
12
|
+
>(rt: Store.Runtime<M, C, P, R>, localId: string) {
|
|
11
13
|
const item = rt.state.items.get(localId)
|
|
12
14
|
|
|
13
|
-
//
|
|
14
|
-
if (
|
|
15
|
+
// State moved on (or never valid) drop any stale inflight entry.
|
|
16
|
+
if (item?.phase !== 'uploading' || !hasIntent(item)) {
|
|
15
17
|
rt.inflightUploads.delete(localId)
|
|
16
18
|
return
|
|
17
19
|
}
|
|
@@ -33,25 +35,24 @@ export async function runUpload<
|
|
|
33
35
|
rt.applyInternal({ type: 'canceled', localId, canceledAt: Date.now() })
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
const applyPaused = (cursor:
|
|
38
|
+
const applyPaused = (cursor: Contracts.Cursor.Any<C>) => {
|
|
37
39
|
const current = rt.state.items.get(localId)
|
|
38
40
|
if (!current || current.phase === 'paused') return
|
|
39
41
|
rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
|
|
40
42
|
}
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
const strategyId = item.intent.strategy
|
|
45
|
+
|
|
46
|
+
// Paused/canceled before the request begins: short-circuit to the right terminal state.
|
|
43
47
|
if (controller.signal.aborted) {
|
|
44
48
|
rt.inflightUploads.delete(localId)
|
|
45
49
|
|
|
46
50
|
if (inflight.mode === 'pause') {
|
|
47
51
|
const cur = rt.state.items.get(localId)
|
|
48
|
-
let strategy: (keyof M & string) | undefined
|
|
49
|
-
if (item && hasIntent(item)) {
|
|
50
|
-
strategy = item.intent.strategy
|
|
51
|
-
}
|
|
52
52
|
|
|
53
53
|
const cursor =
|
|
54
|
-
(cur && hasCursor(cur) ? cur.cursor : undefined) ||
|
|
54
|
+
(cur && hasCursor(cur) ? cur.cursor : undefined) ||
|
|
55
|
+
({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
|
|
55
56
|
|
|
56
57
|
if (cursor) applyPaused(cursor)
|
|
57
58
|
return
|
|
@@ -65,16 +66,15 @@ export async function runUpload<
|
|
|
65
66
|
return
|
|
66
67
|
}
|
|
67
68
|
|
|
68
|
-
const strategyId = item.intent.strategy
|
|
69
69
|
if (!rt.opts.strategies.has(strategyId)) {
|
|
70
70
|
rt.inflightUploads.delete(localId)
|
|
71
|
-
const error
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
const error = new UploadStrategyMissingError(String(strategyId))
|
|
72
|
+
rt.applyInternal({
|
|
73
|
+
type: 'upload.failed',
|
|
74
|
+
localId,
|
|
75
|
+
error,
|
|
75
76
|
retryable: false,
|
|
76
|
-
}
|
|
77
|
-
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
77
|
+
})
|
|
78
78
|
return
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -82,27 +82,35 @@ export async function runUpload<
|
|
|
82
82
|
|
|
83
83
|
if (!strategy) {
|
|
84
84
|
rt.inflightUploads.delete(localId)
|
|
85
|
-
const error
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
const error = new UploadStrategyMissingError(String(item.intent.strategy))
|
|
86
|
+
rt.applyInternal({
|
|
87
|
+
type: 'upload.failed',
|
|
88
|
+
localId,
|
|
89
|
+
error,
|
|
89
90
|
retryable: false,
|
|
90
|
-
}
|
|
91
|
-
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
91
|
+
})
|
|
92
92
|
return
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
// Throttle progress events
|
|
95
|
+
// Throttle progress events to limit re-renders and reducer churn.
|
|
96
96
|
let lastEmit = 0
|
|
97
97
|
const progressThrottle = rt.opts.config.progressThrottleMs
|
|
98
98
|
|
|
99
99
|
try {
|
|
100
100
|
await strategy.start({
|
|
101
|
+
localId,
|
|
102
|
+
purpose: item.purpose,
|
|
101
103
|
file: item.file,
|
|
102
104
|
intent: item.intent,
|
|
103
105
|
signal: controller.signal,
|
|
104
106
|
transport: rt.opts.transport,
|
|
105
107
|
api: rt.opts.api,
|
|
108
|
+
fingerprint: item.fingerprint,
|
|
109
|
+
attempt: item.attempt ?? 1,
|
|
110
|
+
createdAt: item.createdAt,
|
|
111
|
+
steps: item.steps,
|
|
112
|
+
meta: item.meta,
|
|
113
|
+
lastError: [...item.steps].reverse().find((s) => s.error)?.error,
|
|
106
114
|
|
|
107
115
|
readCursor: () => {
|
|
108
116
|
const cur = rt.state.items.get(localId)
|
|
@@ -112,7 +120,7 @@ export async function runUpload<
|
|
|
112
120
|
},
|
|
113
121
|
|
|
114
122
|
persistCursor: (cursorValue) => {
|
|
115
|
-
const tagged
|
|
123
|
+
const tagged = { strategy: strategyId, value: cursorValue } as Contracts.Cursor.Any<C>
|
|
116
124
|
rt.applyInternal({ type: 'cursor.updated', localId, cursor: tagged })
|
|
117
125
|
},
|
|
118
126
|
|
|
@@ -138,23 +146,27 @@ export async function runUpload<
|
|
|
138
146
|
return
|
|
139
147
|
}
|
|
140
148
|
if (controller.signal.aborted && inflight?.mode === 'pause') {
|
|
141
|
-
// Paused
|
|
149
|
+
// Paused after strategy returned: reducer requires a cursor synthesize an
|
|
150
|
+
// empty one if the strategy never persisted progress.
|
|
142
151
|
const cur = rt.state.items.get(localId)
|
|
143
|
-
const cursor =
|
|
152
|
+
const cursor =
|
|
153
|
+
(cur && hasCursor(cur) ? cur.cursor : undefined) ||
|
|
154
|
+
({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
|
|
144
155
|
applyPaused(cursor)
|
|
145
156
|
return
|
|
146
157
|
}
|
|
147
158
|
|
|
148
|
-
//
|
|
159
|
+
// 'completing' phase; finalizeUpload runs via scheduleWork().
|
|
149
160
|
rt.applyInternal({ type: 'upload.ok', localId })
|
|
150
|
-
// finalizeUpload scheduled by scheduleWork() via completing phase
|
|
151
161
|
} catch (err: unknown) {
|
|
152
162
|
rt.inflightUploads.delete(localId)
|
|
153
163
|
|
|
154
164
|
if (controller.signal.aborted) {
|
|
155
165
|
if (inflight?.mode === 'pause') {
|
|
156
166
|
const cur = rt.state.items.get(localId)
|
|
157
|
-
const cursor =
|
|
167
|
+
const cursor =
|
|
168
|
+
(cur && hasCursor(cur) ? cur.cursor : undefined) ||
|
|
169
|
+
({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
|
|
158
170
|
applyPaused(cursor)
|
|
159
171
|
return
|
|
160
172
|
}
|
|
@@ -164,7 +176,7 @@ export async function runUpload<
|
|
|
164
176
|
return
|
|
165
177
|
}
|
|
166
178
|
|
|
167
|
-
//
|
|
179
|
+
// Abort with no mode set: treat as cancel.
|
|
168
180
|
applyCanceled()
|
|
169
181
|
return
|
|
170
182
|
}
|
|
@@ -174,9 +186,12 @@ export async function runUpload<
|
|
|
174
186
|
const errorWithContext: UploadError = itemForContext
|
|
175
187
|
? {
|
|
176
188
|
...error,
|
|
177
|
-
|
|
178
|
-
(
|
|
179
|
-
|
|
189
|
+
context: {
|
|
190
|
+
...((error as { context?: Record<string, unknown> }).context ?? {}),
|
|
191
|
+
filename: itemForContext.fingerprint.name,
|
|
192
|
+
size: (hasFile(itemForContext) && itemForContext.file?.size) ?? 0,
|
|
193
|
+
phase: itemForContext.phase,
|
|
194
|
+
},
|
|
180
195
|
}
|
|
181
196
|
: error
|
|
182
197
|
const decision = retryDecision(rt.opts.config, { phase: 'upload', attempt: 1, error: errorWithContext })
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import { isDevEnv } from '../../utils/guards'
|
|
3
|
+
import type { Engine } from '../engine.types'
|
|
3
4
|
import { dispatch as internalDispatch } from './store.dispatch'
|
|
4
5
|
import { createStoreRuntime } from './store.runtime'
|
|
5
|
-
import type {
|
|
6
|
+
import type { Store } from './store.types'
|
|
6
7
|
|
|
7
8
|
// TODO: add option for debug mode
|
|
8
9
|
|
|
@@ -22,20 +23,21 @@ import type { StoreOptions, StoreRuntime, UploadStore } from './store.types'
|
|
|
22
23
|
* @template P - Purpose string union type
|
|
23
24
|
*
|
|
24
25
|
* @param opts - Store options (config, backend API, strategies, transport, hooks)
|
|
25
|
-
* @returns A configured {@link UploadStore} instance.
|
|
26
|
+
* @returns A configured {@link Store.UploadStore} instance.
|
|
27
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
26
28
|
*/
|
|
27
29
|
export function createUploadStore<
|
|
28
|
-
M extends
|
|
29
|
-
C extends
|
|
30
|
+
M extends Contracts.Intent.Map,
|
|
31
|
+
C extends Contracts.Cursor.Map<M>,
|
|
30
32
|
P extends string,
|
|
31
|
-
R extends
|
|
32
|
-
>(opts:
|
|
33
|
+
R extends Contracts.Result.Base = Contracts.Result.Base,
|
|
34
|
+
>(opts: Store.Options<M, C, P, R>): Store.UploadStore<M, C, P, R> {
|
|
33
35
|
const rt = createStoreRuntime(opts)
|
|
34
36
|
|
|
35
37
|
// Wire dispatch for handlers that need retries without import cycles.
|
|
36
38
|
rt.dispatch = (cmd) => internalDispatch(rt, cmd)
|
|
37
39
|
|
|
38
|
-
const storeProxy: Pick<UploadStore<M, C, P, R>, 'on' | 'off' | 'dispatch' | 'getSnapshot'> = {
|
|
40
|
+
const storeProxy: Pick<Store.UploadStore<M, C, P, R>, 'on' | 'off' | 'dispatch' | 'getSnapshot'> = {
|
|
39
41
|
on: rt.emitter.on.bind(rt.emitter),
|
|
40
42
|
off: rt.emitter.off.bind(rt.emitter),
|
|
41
43
|
dispatch: rt.dispatch,
|
|
@@ -47,7 +49,7 @@ export function createUploadStore<
|
|
|
47
49
|
try {
|
|
48
50
|
plugin.setup(storeProxy)
|
|
49
51
|
} catch (err) {
|
|
50
|
-
if (
|
|
52
|
+
if (isDevEnv()) {
|
|
51
53
|
console.error(`[UploadEngine] Plugin "${plugin.name}" failed to setup:`, err)
|
|
52
54
|
}
|
|
53
55
|
}
|
|
@@ -55,32 +57,34 @@ export function createUploadStore<
|
|
|
55
57
|
|
|
56
58
|
return {
|
|
57
59
|
...storeProxy,
|
|
58
|
-
subscribe: (listener) => {
|
|
60
|
+
subscribe: (listener: () => void) => {
|
|
59
61
|
rt.listeners.add(listener)
|
|
60
62
|
return () => rt.listeners.delete(listener)
|
|
61
63
|
},
|
|
62
|
-
waitFor(localIds) {
|
|
64
|
+
waitFor(localIds: string[]) {
|
|
63
65
|
return waitForOutcomes(rt, localIds)
|
|
64
66
|
},
|
|
65
67
|
}
|
|
66
68
|
}
|
|
67
69
|
|
|
68
|
-
export
|
|
70
|
+
export type { Store } from './store.types'
|
|
69
71
|
|
|
70
|
-
function waitForOutcomes<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
function waitForOutcomes<
|
|
73
|
+
M extends Contracts.Intent.Map,
|
|
74
|
+
C extends Contracts.Cursor.Map<M>,
|
|
75
|
+
P extends string,
|
|
76
|
+
R extends Contracts.Result.Base,
|
|
77
|
+
>(rt: Store.Runtime<M, C, P, R>, localIds: string[]): Promise<Array<Engine.Outcome<R>>> {
|
|
74
78
|
if (localIds.length === 0) return Promise.resolve([])
|
|
75
79
|
|
|
76
80
|
const pending = new Set(localIds)
|
|
77
|
-
const outcomes = new Map<string,
|
|
81
|
+
const outcomes = new Map<string, Engine.Outcome<R>>()
|
|
78
82
|
|
|
79
83
|
const update = () => {
|
|
80
84
|
for (const id of pending) {
|
|
81
85
|
const item = rt.state.items.get(id)
|
|
82
86
|
if (!item) {
|
|
83
|
-
outcomes.set(id, { localId: id, status: 'missing' })
|
|
87
|
+
outcomes.set(id, { localId: id, status: 'missing', reason: 'never-existed' })
|
|
84
88
|
pending.delete(id)
|
|
85
89
|
continue
|
|
86
90
|
}
|
|
@@ -107,7 +111,9 @@ function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends
|
|
|
107
111
|
update()
|
|
108
112
|
|
|
109
113
|
if (pending.size === 0) {
|
|
110
|
-
return Promise.resolve(
|
|
114
|
+
return Promise.resolve(
|
|
115
|
+
localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing', reason: 'never-existed' }),
|
|
116
|
+
)
|
|
111
117
|
}
|
|
112
118
|
|
|
113
119
|
return new Promise((resolve) => {
|
|
@@ -115,7 +121,7 @@ function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends
|
|
|
115
121
|
update()
|
|
116
122
|
if (pending.size === 0) {
|
|
117
123
|
rt.listeners.delete(listener)
|
|
118
|
-
resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing' }))
|
|
124
|
+
resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing', reason: 'never-existed' }))
|
|
119
125
|
}
|
|
120
126
|
}
|
|
121
127
|
rt.listeners.add(listener)
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import type {
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import type { Engine } from '../engine.types'
|
|
3
3
|
import { handleAddFiles } from './handlers/add-file'
|
|
4
4
|
import { handleCancel } from './handlers/cancel'
|
|
5
5
|
import { handlePause } from './handlers/pause'
|
|
6
|
-
import type {
|
|
6
|
+
import type { Store } from './store.types'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Dispatches a command against the runtime.
|
|
@@ -16,10 +16,12 @@ import type { StoreRuntime } from './store.types'
|
|
|
16
16
|
* @template C - Cursor map type
|
|
17
17
|
* @template P - Purpose string union type
|
|
18
18
|
*/
|
|
19
|
-
export function dispatch<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
export function dispatch<
|
|
20
|
+
M extends Contracts.Intent.Map,
|
|
21
|
+
C extends Contracts.Cursor.Map<M>,
|
|
22
|
+
P extends string,
|
|
23
|
+
R extends Contracts.Result.Base,
|
|
24
|
+
>(rt: Store.Runtime<M, C, P, R>, cmd: Engine.Command<P>) {
|
|
23
25
|
// Bulk operations
|
|
24
26
|
if (cmd.type === 'startAll') {
|
|
25
27
|
const purpose = cmd.purpose
|
|
@@ -54,7 +56,7 @@ export function dispatch<M extends IntentMap, C extends CursorMap<M>, P extends
|
|
|
54
56
|
}
|
|
55
57
|
|
|
56
58
|
if (cmd.type === 'addFiles') {
|
|
57
|
-
handleAddFiles(rt, cmd.files, cmd.purpose)
|
|
59
|
+
handleAddFiles(rt, cmd.files, cmd.purpose, cmd.meta ?? {})
|
|
58
60
|
return
|
|
59
61
|
}
|
|
60
62
|
|