@gentleduck/upload 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1417 -0
- package/dist/index.js +6 -0
- package/package.json +43 -0
- package/src/core/client/config.types.ts +77 -0
- package/src/core/client/index.ts +4 -0
- package/src/core/client/plugins.types.ts +46 -0
- package/src/core/client/retry.types.ts +6 -0
- package/src/core/client/uploadClient.ts +19 -0
- package/src/core/contracts/backendApi.ts +62 -0
- package/src/core/contracts/cursor.types.ts +23 -0
- package/src/core/contracts/errors.types.ts +67 -0
- package/src/core/contracts/fingerprint.types.ts +18 -0
- package/src/core/contracts/index.ts +8 -0
- package/src/core/contracts/intent.types.ts +39 -0
- package/src/core/contracts/result.types.ts +11 -0
- package/src/core/contracts/strategy.types.ts +45 -0
- package/src/core/contracts/transport/index.ts +2 -0
- package/src/core/contracts/transport/transport.libs.ts +100 -0
- package/src/core/contracts/transport/transport.ts +166 -0
- package/src/core/contracts/transport/transport.types.ts +44 -0
- package/src/core/engine/commands.types.ts +17 -0
- package/src/core/engine/event-map.types.ts +45 -0
- package/src/core/engine/index.ts +8 -0
- package/src/core/engine/internal-events.types.ts +222 -0
- package/src/core/engine/outcome.types.ts +9 -0
- package/src/core/engine/progress.types.ts +15 -0
- package/src/core/engine/reducer.ts +435 -0
- package/src/core/engine/store/handlers/add-file.ts +103 -0
- package/src/core/engine/store/handlers/cancel.ts +56 -0
- package/src/core/engine/store/handlers/clean-up.ts +66 -0
- package/src/core/engine/store/handlers/create-intent.ts +76 -0
- package/src/core/engine/store/handlers/finalize.ts +59 -0
- package/src/core/engine/store/handlers/index.ts +7 -0
- package/src/core/engine/store/handlers/pause.ts +16 -0
- package/src/core/engine/store/handlers/run-upload.ts +192 -0
- package/src/core/engine/store/index.ts +123 -0
- package/src/core/engine/store/store.dispatch.ts +78 -0
- package/src/core/engine/store/store.libs.ts +169 -0
- package/src/core/engine/store/store.runtime.ts +336 -0
- package/src/core/engine/store/store.schedule.ts +116 -0
- package/src/core/engine/store/store.types.ts +250 -0
- package/src/core/engine/validation/file.ts +77 -0
- package/src/core/engine/validation/index.ts +3 -0
- package/src/core/engine/validation/intent.ts +55 -0
- package/src/core/engine/validation/list.ts +50 -0
- package/src/core/index.ts +5 -0
- package/src/core/persistence/adapters.indexeddb.ts +92 -0
- package/src/core/persistence/adapters.local.ts +22 -0
- package/src/core/persistence/adapters.memory.ts +15 -0
- package/src/core/persistence/index.ts +5 -0
- package/src/core/persistence/persistence.ts +194 -0
- package/src/core/persistence/persistence.types.ts +66 -0
- package/src/core/utils/async.ts +3 -0
- package/src/core/utils/constants.ts +32 -0
- package/src/core/utils/emitter/emitter.types.ts +23 -0
- package/src/core/utils/emitter/index.ts +68 -0
- package/src/core/utils/fingerprint.ts +20 -0
- package/src/core/utils/guards.ts +3 -0
- package/src/core/utils/id.ts +3 -0
- package/src/core/utils/index.ts +6 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +2 -0
- package/src/react/upload-provider.tsx +96 -0
- package/src/react/use-uploader.tsx +115 -0
- package/src/strategies/index.ts +15 -0
- package/src/strategies/multipart/index.ts +243 -0
- package/src/strategies/post/index.ts +93 -0
- package/src/strategies/registry/index.ts +30 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import { validateIntent } from '../../validation'
|
|
3
|
+
import { normalizeError, retryDecision, sleep } from '../store.libs'
|
|
4
|
+
import type { StoreRuntime } from '../store.types'
|
|
5
|
+
|
|
6
|
+
export async function createIntent<
|
|
7
|
+
M extends IntentMap,
|
|
8
|
+
C extends CursorMap<M>,
|
|
9
|
+
P extends string,
|
|
10
|
+
R extends UploadResultBase,
|
|
11
|
+
>(rt: StoreRuntime<M, C, P, R>, localId: string) {
|
|
12
|
+
const item = rt.state.items.get(localId)
|
|
13
|
+
if (!item || item.phase !== 'creating_intent') return
|
|
14
|
+
if (rt.inflightIntents.has(localId)) return
|
|
15
|
+
|
|
16
|
+
const controller = new AbortController()
|
|
17
|
+
rt.inflightIntents.set(localId, controller)
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const intent = await rt.opts.api.createIntent(
|
|
21
|
+
{
|
|
22
|
+
purpose: item.purpose,
|
|
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 },
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
rt.inflightIntents.delete(localId)
|
|
32
|
+
|
|
33
|
+
// Item might have been canceled while intent was creating
|
|
34
|
+
const current = rt.state.items.get(localId)
|
|
35
|
+
if (!current || current.phase !== 'creating_intent') return
|
|
36
|
+
|
|
37
|
+
// Validate intent from backend
|
|
38
|
+
const intentError = validateIntent(intent, intent.strategy)
|
|
39
|
+
if (intentError) {
|
|
40
|
+
const error: UploadError = {
|
|
41
|
+
code: 'validation_failed',
|
|
42
|
+
message: `Invalid intent from backend: ${intentError.message}`,
|
|
43
|
+
cause: intentError,
|
|
44
|
+
retryable: false,
|
|
45
|
+
}
|
|
46
|
+
rt.applyInternal({ type: 'intent.failed', localId, error, retryable: false })
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
rt.applyInternal({ type: 'intent.ok', localId, intent })
|
|
51
|
+
} catch (err: unknown) {
|
|
52
|
+
rt.inflightIntents.delete(localId)
|
|
53
|
+
|
|
54
|
+
if (controller.signal.aborted) {
|
|
55
|
+
// canceled
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const error = normalizeError(err, rt.opts.errorNormalizer)
|
|
60
|
+
const errorWithContext: UploadError = {
|
|
61
|
+
...error,
|
|
62
|
+
message: `${error.message} (file: ${item.fingerprint.name}, size: ${item.file.size} bytes, purpose: ${item.purpose})`,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const decision = retryDecision(rt.opts.config, { phase: 'intent', attempt: item.attempt, error: errorWithContext })
|
|
66
|
+
|
|
67
|
+
rt.applyInternal({ type: 'intent.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
68
|
+
|
|
69
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
70
|
+
rt.enqueueEffect(async () => {
|
|
71
|
+
await sleep(decision.delayMs)
|
|
72
|
+
rt.dispatch({ type: 'retry', localId })
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import { hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
|
|
3
|
+
import type { StoreRuntime } from '../store.types'
|
|
4
|
+
|
|
5
|
+
export async function finalizeUpload<
|
|
6
|
+
M extends IntentMap,
|
|
7
|
+
C extends CursorMap<M>,
|
|
8
|
+
P extends string,
|
|
9
|
+
R extends UploadResultBase,
|
|
10
|
+
>(rt: StoreRuntime<M, C, P, R>, localId: string) {
|
|
11
|
+
const item = rt.state.items.get(localId)
|
|
12
|
+
if (!item || item.phase !== 'completing') return
|
|
13
|
+
if (rt.inflightCompletes.has(localId)) return
|
|
14
|
+
|
|
15
|
+
const controller = new AbortController()
|
|
16
|
+
rt.inflightCompletes.set(localId, controller)
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
// Final commit step (backend should verify upload, then mark/commit it).
|
|
20
|
+
// Strategy-specific API calls (multipart/tus) must happen inside the strategy itself.
|
|
21
|
+
const result = await rt.opts.api.complete({ fileId: item.intent.fileId }, { signal: controller.signal })
|
|
22
|
+
rt.inflightCompletes.delete(localId)
|
|
23
|
+
|
|
24
|
+
// Item might have been canceled
|
|
25
|
+
const current = rt.state.items.get(localId)
|
|
26
|
+
if (!current || current.phase !== 'completing') return
|
|
27
|
+
|
|
28
|
+
rt.applyInternal({ type: 'complete.ok', localId, result })
|
|
29
|
+
} catch (err: unknown) {
|
|
30
|
+
rt.inflightCompletes.delete(localId)
|
|
31
|
+
|
|
32
|
+
if (controller.signal.aborted) return
|
|
33
|
+
|
|
34
|
+
const error = normalizeError(err, rt.opts.errorNormalizer)
|
|
35
|
+
|
|
36
|
+
const itemForContext = rt.state.items.get(localId)
|
|
37
|
+
const errorWithContext: UploadError = itemForContext
|
|
38
|
+
? {
|
|
39
|
+
...error,
|
|
40
|
+
message: `${error.message} (file: ${itemForContext.fingerprint.name}, fileId: ${
|
|
41
|
+
(hasIntent(itemForContext) && itemForContext.intent?.fileId) ?? 'unknown'
|
|
42
|
+
})`,
|
|
43
|
+
}
|
|
44
|
+
: error
|
|
45
|
+
|
|
46
|
+
// Use actual attempt number from item state, defaulting to 1
|
|
47
|
+
const attempt = itemForContext && 'attempt' in itemForContext ? (itemForContext.attempt ?? 1) : 1
|
|
48
|
+
const decision = retryDecision(rt.opts.config, { phase: 'complete', attempt, error: errorWithContext })
|
|
49
|
+
|
|
50
|
+
rt.applyInternal({ type: 'complete.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
51
|
+
|
|
52
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
53
|
+
rt.enqueueEffect(async () => {
|
|
54
|
+
await sleep(decision.delayMs)
|
|
55
|
+
rt.dispatch({ type: 'retry', localId })
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import type { StoreRuntime } from '../store.types'
|
|
3
|
+
|
|
4
|
+
export function handlePause<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
5
|
+
rt: StoreRuntime<M, C, P, R>,
|
|
6
|
+
localId: string,
|
|
7
|
+
) {
|
|
8
|
+
const inflight = rt.inflightUploads.get(localId)
|
|
9
|
+
if (inflight) {
|
|
10
|
+
inflight.mode = 'pause'
|
|
11
|
+
inflight.controller.abort({ reason: 'pause' })
|
|
12
|
+
return
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// If not inflight but queued, reducer already reverts it to ready.
|
|
16
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { AnyCursor, CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
|
|
2
|
+
import { hasCursor, hasFile, hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
|
|
3
|
+
import type { StoreRuntime } from '../store.types'
|
|
4
|
+
|
|
5
|
+
export async function runUpload<
|
|
6
|
+
M extends IntentMap,
|
|
7
|
+
C extends CursorMap<M>,
|
|
8
|
+
P extends string,
|
|
9
|
+
R extends UploadResultBase,
|
|
10
|
+
>(rt: StoreRuntime<M, C, P, R>, localId: string) {
|
|
11
|
+
const item = rt.state.items.get(localId)
|
|
12
|
+
|
|
13
|
+
// If the state moved on (or we never had a valid upload), drop any inflight entry.
|
|
14
|
+
if (!item || item.phase !== 'uploading' || !hasIntent(item)) {
|
|
15
|
+
rt.inflightUploads.delete(localId)
|
|
16
|
+
return
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let inflight = rt.inflightUploads.get(localId)
|
|
20
|
+
if (!inflight) {
|
|
21
|
+
inflight = { controller: new AbortController(), mode: 'normal', started: false }
|
|
22
|
+
rt.inflightUploads.set(localId, inflight)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (inflight.started) return
|
|
26
|
+
inflight.started = true
|
|
27
|
+
|
|
28
|
+
const controller = inflight.controller
|
|
29
|
+
|
|
30
|
+
const applyCanceled = () => {
|
|
31
|
+
const current = rt.state.items.get(localId)
|
|
32
|
+
if (!current || current.phase === 'canceled') return
|
|
33
|
+
rt.applyInternal({ type: 'canceled', localId, canceledAt: Date.now() })
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const applyPaused = (cursor: AnyCursor<C>) => {
|
|
37
|
+
const current = rt.state.items.get(localId)
|
|
38
|
+
if (!current || current.phase === 'paused') return
|
|
39
|
+
rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// If paused/canceled before the request begins, complete it immediately.
|
|
43
|
+
if (controller.signal.aborted) {
|
|
44
|
+
rt.inflightUploads.delete(localId)
|
|
45
|
+
|
|
46
|
+
if (inflight.mode === 'pause') {
|
|
47
|
+
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
|
+
|
|
53
|
+
const cursor =
|
|
54
|
+
(cur && hasCursor(cur) ? cur.cursor : undefined) || (strategy ? { strategy, value: undefined } : undefined)
|
|
55
|
+
|
|
56
|
+
if (cursor) applyPaused(cursor)
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (inflight.mode === 'cancel') {
|
|
61
|
+
applyCanceled()
|
|
62
|
+
return
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const strategyId = item.intent.strategy
|
|
69
|
+
if (!rt.opts.strategies.has(strategyId)) {
|
|
70
|
+
rt.inflightUploads.delete(localId)
|
|
71
|
+
const error: UploadError = {
|
|
72
|
+
code: 'strategy_missing',
|
|
73
|
+
message: `Strategy not found: ${strategyId}`,
|
|
74
|
+
strategy: String(strategyId),
|
|
75
|
+
retryable: false,
|
|
76
|
+
}
|
|
77
|
+
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const strategy = rt.opts.strategies.get(strategyId)
|
|
82
|
+
|
|
83
|
+
if (!strategy) {
|
|
84
|
+
rt.inflightUploads.delete(localId)
|
|
85
|
+
const error: UploadError = {
|
|
86
|
+
code: 'strategy_missing',
|
|
87
|
+
message: `Strategy not found: ${item.intent.strategy}`,
|
|
88
|
+
strategy: String(item.intent.strategy),
|
|
89
|
+
retryable: false,
|
|
90
|
+
}
|
|
91
|
+
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Throttle progress events
|
|
96
|
+
let lastEmit = 0
|
|
97
|
+
const progressThrottle = rt.opts.config.progressThrottleMs
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
await strategy.start({
|
|
101
|
+
file: item.file,
|
|
102
|
+
intent: item.intent,
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
transport: rt.opts.transport,
|
|
105
|
+
api: rt.opts.api,
|
|
106
|
+
|
|
107
|
+
readCursor: () => {
|
|
108
|
+
const cur = rt.state.items.get(localId)
|
|
109
|
+
if (!cur || !hasCursor(cur) || !cur.cursor) return undefined
|
|
110
|
+
if (cur.cursor.strategy !== strategyId) return undefined
|
|
111
|
+
return cur.cursor.value
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
persistCursor: (cursorValue) => {
|
|
115
|
+
const tagged: AnyCursor<C> = { strategy: strategyId, value: cursorValue }
|
|
116
|
+
rt.applyInternal({ type: 'cursor.updated', localId, cursor: tagged })
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
reportProgress: (p) => {
|
|
120
|
+
const now = Date.now()
|
|
121
|
+
if (now - lastEmit < progressThrottle) return
|
|
122
|
+
|
|
123
|
+
rt.applyInternal({
|
|
124
|
+
type: 'upload.progress',
|
|
125
|
+
localId,
|
|
126
|
+
uploadedBytes: p.uploadedBytes,
|
|
127
|
+
totalBytes: p.totalBytes,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
lastEmit = now
|
|
131
|
+
},
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
rt.inflightUploads.delete(localId)
|
|
135
|
+
|
|
136
|
+
if (inflight?.mode === 'cancel') {
|
|
137
|
+
applyCanceled()
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (controller.signal.aborted && inflight?.mode === 'pause') {
|
|
141
|
+
// Paused at the end; reducer expects cursor - if not present, keep last-known or synthesize.
|
|
142
|
+
const cur = rt.state.items.get(localId)
|
|
143
|
+
const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
|
|
144
|
+
applyPaused(cursor)
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Normal completion -> completing
|
|
149
|
+
rt.applyInternal({ type: 'upload.ok', localId })
|
|
150
|
+
// finalizeUpload scheduled by scheduleWork() via completing phase
|
|
151
|
+
} catch (err: unknown) {
|
|
152
|
+
rt.inflightUploads.delete(localId)
|
|
153
|
+
|
|
154
|
+
if (controller.signal.aborted) {
|
|
155
|
+
if (inflight?.mode === 'pause') {
|
|
156
|
+
const cur = rt.state.items.get(localId)
|
|
157
|
+
const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
|
|
158
|
+
applyPaused(cursor)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (inflight?.mode === 'cancel') {
|
|
163
|
+
applyCanceled()
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Unknown abort -> treat as cancel
|
|
168
|
+
applyCanceled()
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const error = normalizeError(err, rt.opts.errorNormalizer)
|
|
173
|
+
const itemForContext = rt.state.items.get(localId)
|
|
174
|
+
const errorWithContext: UploadError = itemForContext
|
|
175
|
+
? {
|
|
176
|
+
...error,
|
|
177
|
+
message: `${error.message} (file: ${itemForContext.fingerprint.name}, size: ${
|
|
178
|
+
(hasFile(itemForContext) && itemForContext.file?.size) ?? 0
|
|
179
|
+
} bytes, phase: ${itemForContext.phase})`,
|
|
180
|
+
}
|
|
181
|
+
: error
|
|
182
|
+
const decision = retryDecision(rt.opts.config, { phase: 'upload', attempt: 1, error: errorWithContext })
|
|
183
|
+
rt.applyInternal({ type: 'upload.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
184
|
+
|
|
185
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
186
|
+
rt.enqueueEffect(async () => {
|
|
187
|
+
await sleep(decision.delayMs)
|
|
188
|
+
rt.dispatch({ type: 'retry', localId })
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
|
|
2
|
+
import type { UploadOutcome } from '../outcome.types'
|
|
3
|
+
import { dispatch as internalDispatch } from './store.dispatch'
|
|
4
|
+
import { createStoreRuntime } from './store.runtime'
|
|
5
|
+
import type { StoreOptions, StoreRuntime, UploadStore } from './store.types'
|
|
6
|
+
|
|
7
|
+
// TODO: add option for debug mode
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Creates an upload store instance.
|
|
11
|
+
*
|
|
12
|
+
* This is the main runtime object you interact with from the outside:
|
|
13
|
+
* - dispatch user commands (add files, start, pause, cancel, resume)
|
|
14
|
+
* - subscribe to state changes (for UI rendering)
|
|
15
|
+
* - subscribe to typed events (for progress, errors, and lifecycle)
|
|
16
|
+
*
|
|
17
|
+
* Plugins are initialized once during construction and receive a minimal store
|
|
18
|
+
* proxy (events, dispatch, snapshot access) to avoid tight coupling.
|
|
19
|
+
*
|
|
20
|
+
* @template M - Intent map type (keyed by strategy id)
|
|
21
|
+
* @template C - Cursor map type (keyed by strategy id)
|
|
22
|
+
* @template P - Purpose string union type
|
|
23
|
+
*
|
|
24
|
+
* @param opts - Store options (config, backend API, strategies, transport, hooks)
|
|
25
|
+
* @returns A configured {@link UploadStore} instance.
|
|
26
|
+
*/
|
|
27
|
+
export function createUploadStore<
|
|
28
|
+
M extends IntentMap,
|
|
29
|
+
C extends CursorMap<M>,
|
|
30
|
+
P extends string,
|
|
31
|
+
R extends UploadResultBase = UploadResultBase,
|
|
32
|
+
>(opts: StoreOptions<M, C, P, R>): UploadStore<M, C, P, R> {
|
|
33
|
+
const rt = createStoreRuntime(opts)
|
|
34
|
+
|
|
35
|
+
// Wire dispatch for handlers that need retries without import cycles.
|
|
36
|
+
rt.dispatch = (cmd) => internalDispatch(rt, cmd)
|
|
37
|
+
|
|
38
|
+
const storeProxy: Pick<UploadStore<M, C, P, R>, 'on' | 'off' | 'dispatch' | 'getSnapshot'> = {
|
|
39
|
+
on: rt.emitter.on.bind(rt.emitter),
|
|
40
|
+
off: rt.emitter.off.bind(rt.emitter),
|
|
41
|
+
dispatch: rt.dispatch,
|
|
42
|
+
getSnapshot: () => rt.state,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Plugin setup
|
|
46
|
+
for (const plugin of opts.plugins || []) {
|
|
47
|
+
try {
|
|
48
|
+
plugin.setup(storeProxy)
|
|
49
|
+
} catch (err) {
|
|
50
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
51
|
+
console.error(`[UploadEngine] Plugin "${plugin.name}" failed to setup:`, err)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
...storeProxy,
|
|
58
|
+
subscribe: (listener) => {
|
|
59
|
+
rt.listeners.add(listener)
|
|
60
|
+
return () => rt.listeners.delete(listener)
|
|
61
|
+
},
|
|
62
|
+
waitFor(localIds) {
|
|
63
|
+
return waitForOutcomes(rt, localIds)
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export * from './store.types'
|
|
69
|
+
|
|
70
|
+
function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
71
|
+
rt: StoreRuntime<M, C, P, R>,
|
|
72
|
+
localIds: string[],
|
|
73
|
+
): Promise<Array<UploadOutcome<R>>> {
|
|
74
|
+
if (localIds.length === 0) return Promise.resolve([])
|
|
75
|
+
|
|
76
|
+
const pending = new Set(localIds)
|
|
77
|
+
const outcomes = new Map<string, UploadOutcome<R>>()
|
|
78
|
+
|
|
79
|
+
const update = () => {
|
|
80
|
+
for (const id of pending) {
|
|
81
|
+
const item = rt.state.items.get(id)
|
|
82
|
+
if (!item) {
|
|
83
|
+
outcomes.set(id, { localId: id, status: 'missing' })
|
|
84
|
+
pending.delete(id)
|
|
85
|
+
continue
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (item.phase === 'completed') {
|
|
89
|
+
outcomes.set(id, { localId: id, status: 'completed', completedBy: item.completedBy, result: item.result })
|
|
90
|
+
pending.delete(id)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (item.phase === 'error') {
|
|
95
|
+
outcomes.set(id, { localId: id, status: 'error', error: item.error })
|
|
96
|
+
pending.delete(id)
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (item.phase === 'canceled') {
|
|
101
|
+
outcomes.set(id, { localId: id, status: 'canceled' })
|
|
102
|
+
pending.delete(id)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
update()
|
|
108
|
+
|
|
109
|
+
if (pending.size === 0) {
|
|
110
|
+
return Promise.resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing' }))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const listener = () => {
|
|
115
|
+
update()
|
|
116
|
+
if (pending.size === 0) {
|
|
117
|
+
rt.listeners.delete(listener)
|
|
118
|
+
resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing' }))
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
rt.listeners.add(listener)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
|
|
2
|
+
import type { UploadCommand } from '../commands.types'
|
|
3
|
+
import { handleAddFiles } from './handlers/add-file'
|
|
4
|
+
import { handleCancel } from './handlers/cancel'
|
|
5
|
+
import { handlePause } from './handlers/pause'
|
|
6
|
+
import type { StoreRuntime } from './store.types'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Dispatches a command against the runtime.
|
|
10
|
+
*
|
|
11
|
+
* Some commands are expanded into multiple operations (e.g. startAll/pauseAll/cancelAll).
|
|
12
|
+
* Some commands need imperative side effects (pause/cancel/addFiles) and therefore call
|
|
13
|
+
* handler functions before/after applying reducer transitions.
|
|
14
|
+
*
|
|
15
|
+
* @template M - Intent map type
|
|
16
|
+
* @template C - Cursor map type
|
|
17
|
+
* @template P - Purpose string union type
|
|
18
|
+
*/
|
|
19
|
+
export function dispatch<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
|
|
20
|
+
rt: StoreRuntime<M, C, P, R>,
|
|
21
|
+
cmd: UploadCommand<P>,
|
|
22
|
+
) {
|
|
23
|
+
// Bulk operations
|
|
24
|
+
if (cmd.type === 'startAll') {
|
|
25
|
+
const purpose = cmd.purpose
|
|
26
|
+
Array.from(rt.state.items.values())
|
|
27
|
+
.filter((item) => item.phase === 'ready' && (!purpose || item.purpose === purpose))
|
|
28
|
+
.forEach((item) => rt.dispatch({ type: 'start', localId: item.localId }))
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (cmd.type === 'pauseAll') {
|
|
33
|
+
const purpose = cmd.purpose
|
|
34
|
+
Array.from(rt.state.items.values())
|
|
35
|
+
.filter((item) => item.phase === 'uploading' && (!purpose || item.purpose === purpose))
|
|
36
|
+
.forEach((item) => rt.dispatch({ type: 'pause', localId: item.localId }))
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (cmd.type === 'cancelAll') {
|
|
41
|
+
const purpose = cmd.purpose
|
|
42
|
+
Array.from(rt.state.items.values())
|
|
43
|
+
.filter(
|
|
44
|
+
(item) => item.phase !== 'completed' && item.phase !== 'canceled' && (!purpose || item.purpose === purpose),
|
|
45
|
+
)
|
|
46
|
+
.forEach((item) => rt.dispatch({ type: 'cancel', localId: item.localId }))
|
|
47
|
+
return
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (cmd.type === 'addFiles') {
|
|
51
|
+
handleAddFiles(rt, cmd.files, cmd.purpose)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (cmd.type === 'pause') {
|
|
56
|
+
handlePause(rt, cmd.localId)
|
|
57
|
+
rt.applyCommand(cmd)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (cmd.type === 'cancel') {
|
|
62
|
+
handleCancel(rt, cmd.localId)
|
|
63
|
+
rt.applyCommand(cmd)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (cmd.type === 'start') {
|
|
68
|
+
rt.applyCommand(cmd)
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (cmd.type === 'resume') {
|
|
73
|
+
rt.applyCommand(cmd)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
rt.applyCommand(cmd)
|
|
78
|
+
}
|