@gentleduck/upload 0.2.4 → 0.2.6
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 +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
import type { Contracts } from '../../contracts'
|
|
2
|
+
import { fingerprintMatches, computeFingerprint as fp } from '../../utils/fingerprint'
|
|
3
|
+
import { generateId } from '../../utils/id'
|
|
4
|
+
import type { Engine } from '../engine.types'
|
|
5
|
+
import type { Reducer } from '../reducer'
|
|
6
|
+
import { validateFileList, validateIntent } from '../validation'
|
|
7
|
+
import {
|
|
8
|
+
acquireSlot,
|
|
9
|
+
attachSlot,
|
|
10
|
+
calculateFileChecksum,
|
|
11
|
+
computeFingerprint,
|
|
12
|
+
getActiveCount,
|
|
13
|
+
getAttempt,
|
|
14
|
+
hasCursor,
|
|
15
|
+
hasFile,
|
|
16
|
+
hasIntent,
|
|
17
|
+
isMultipartIntent,
|
|
18
|
+
normalizeError,
|
|
19
|
+
releaseAllSlots,
|
|
20
|
+
retryDecision,
|
|
21
|
+
scheduleRetry,
|
|
22
|
+
withErrorContext,
|
|
23
|
+
} from './store.libs'
|
|
24
|
+
import type { Store } from './store.types'
|
|
25
|
+
|
|
26
|
+
// ============================================================================
|
|
27
|
+
// addFiles -- validate + queue per-file checksum + dedupe effect
|
|
28
|
+
// ============================================================================
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Handler for the `addFiles` command.
|
|
32
|
+
*
|
|
33
|
+
* Runs synchronous validation, inserts accepted items into state, then
|
|
34
|
+
* enqueues an async effect per item that computes the checksum, performs
|
|
35
|
+
* server dedupe, and transitions the item out of `validating`.
|
|
36
|
+
*
|
|
37
|
+
* @param rt Store runtime.
|
|
38
|
+
* @param files Files passed to `dispatch({ type: 'addFiles' })`.
|
|
39
|
+
* @param purpose Purpose key associated with the batch.
|
|
40
|
+
* @template M Intent map keyed by strategy id.
|
|
41
|
+
* @template C Cursor map keyed by strategy id.
|
|
42
|
+
* @template P Purpose discriminator string.
|
|
43
|
+
* @template R Backend result payload.
|
|
44
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
45
|
+
*/
|
|
46
|
+
export function handleAddFiles<
|
|
47
|
+
M extends Contracts.IntentMap,
|
|
48
|
+
C extends Contracts.CursorMap<M>,
|
|
49
|
+
P extends string,
|
|
50
|
+
R extends Contracts.IResultBase,
|
|
51
|
+
>(rt: Store.IRuntime<M, C, P, R>, files: File[], purpose: P) {
|
|
52
|
+
// `maxFiles` baseline. `getActiveCount` memoizes per `state` ref so
|
|
53
|
+
// calling `addFiles` N times stays O(N) instead of compounding to O(N²).
|
|
54
|
+
const existingCount = getActiveCount(rt, purpose)
|
|
55
|
+
|
|
56
|
+
const { valid, rejected } = validateFileList(files, purpose, rt.opts.config, existingCount)
|
|
57
|
+
|
|
58
|
+
for (const { file, reason } of rejected) {
|
|
59
|
+
rt.emitter.emit('file.rejected', { reason, file })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const now = Date.now()
|
|
63
|
+
const toAdd: Array<{
|
|
64
|
+
localId: string
|
|
65
|
+
purpose: P
|
|
66
|
+
file: File
|
|
67
|
+
fingerprint: Contracts.IFileFingerprint
|
|
68
|
+
createdAt: number
|
|
69
|
+
}> = []
|
|
70
|
+
|
|
71
|
+
for (const file of valid) {
|
|
72
|
+
const localId = generateId()
|
|
73
|
+
// Guard a user-supplied fingerprinter. A throw used to take down the
|
|
74
|
+
// entire addFiles call and drop the remaining files; fall back to the
|
|
75
|
+
// built-in fingerprint instead so one bad implementation does not
|
|
76
|
+
// strand a multi-file selection.
|
|
77
|
+
let fingerprint: Contracts.IFileFingerprint
|
|
78
|
+
try {
|
|
79
|
+
fingerprint = (rt.opts.fingerprint ?? computeFingerprint)(file)
|
|
80
|
+
} catch (err) {
|
|
81
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
82
|
+
console.error('[UploadEngine] custom fingerprint threw; falling back to built-in:', err)
|
|
83
|
+
}
|
|
84
|
+
fingerprint = computeFingerprint(file)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
|
|
88
|
+
|
|
89
|
+
// Allocate the controller sync so a cancel arriving before the effect
|
|
90
|
+
// body runs can find and abort it via `pendingValidations`.
|
|
91
|
+
const validationCtl = new AbortController()
|
|
92
|
+
rt.pendingValidations.set(localId, validationCtl)
|
|
93
|
+
|
|
94
|
+
rt.enqueueEffect(
|
|
95
|
+
async (effectSignal) => {
|
|
96
|
+
const release = attachSlot(rt.pendingValidations, localId, validationCtl, effectSignal)
|
|
97
|
+
const signal = validationCtl.signal
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
// Config-based rules already ran in `validateFileList` above.
|
|
101
|
+
// Custom hook runs here so it can be async-aware via opts.validateFile.
|
|
102
|
+
let customReason: Contracts.RejectReason | null | undefined
|
|
103
|
+
try {
|
|
104
|
+
customReason = rt.opts.validateFile?.(file, purpose)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
// A throwing user predicate would otherwise strand the item in
|
|
107
|
+
// `validating` (the effect's outer catch swallows). Treat it as
|
|
108
|
+
// a `validation_failed` so the item moves to a terminal phase
|
|
109
|
+
// and the consumer can surface the error.
|
|
110
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
111
|
+
console.error('[UploadEngine] validateFile predicate threw; marking item as validation_failed:', err)
|
|
112
|
+
}
|
|
113
|
+
customReason = {
|
|
114
|
+
code: 'type_not_allowed',
|
|
115
|
+
allowed: [],
|
|
116
|
+
got: file.type || file.name,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (customReason) {
|
|
120
|
+
rt.applyInternal({ type: 'validation.failed', localId, reason: customReason })
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (signal.aborted) return
|
|
125
|
+
|
|
126
|
+
let checksum: string | undefined
|
|
127
|
+
const checksumCap = rt.opts.config.checksumMaxSize
|
|
128
|
+
const skipChecksum = checksumCap !== null && file.size > checksumCap
|
|
129
|
+
if (skipChecksum) {
|
|
130
|
+
// Above the configured threshold: skip the digest (and the dedupe
|
|
131
|
+
// lookup downstream). Avoids buffering huge files in heap because
|
|
132
|
+
// browser SHA-256 has no streaming variant.
|
|
133
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
134
|
+
console.warn(
|
|
135
|
+
`[UploadEngine] Skipping checksum for "${file.name}" (size ${file.size} > checksumMaxSize ${checksumCap}); dedupe is unavailable for this upload.`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
try {
|
|
140
|
+
checksum = await calculateFileChecksum(file, signal, rt.opts.config.checksumChunkBytes)
|
|
141
|
+
if (signal.aborted) return
|
|
142
|
+
const updatedFingerprint = { ...fingerprint, checksum }
|
|
143
|
+
rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
|
|
144
|
+
} catch (err) {
|
|
145
|
+
// Checksum failure is non-fatal; continue without dedupe.
|
|
146
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
147
|
+
console.warn('[UploadEngine] Failed to calculate checksum:', err)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (signal.aborted) return
|
|
153
|
+
|
|
154
|
+
if (checksum && rt.opts.api.findByChecksum) {
|
|
155
|
+
try {
|
|
156
|
+
const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose }, { signal })
|
|
157
|
+
if (signal.aborted) return
|
|
158
|
+
if (existingFile) {
|
|
159
|
+
const currentItem = rt.state.items.get(localId)
|
|
160
|
+
if (currentItem && currentItem.phase === 'validating') {
|
|
161
|
+
rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (err) {
|
|
166
|
+
// Dedupe failure is non-fatal; fall through to normal upload.
|
|
167
|
+
if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
|
|
168
|
+
console.warn('[UploadEngine] Failed to check for existing file:', err)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (signal.aborted) return
|
|
174
|
+
|
|
175
|
+
rt.applyInternal({ type: 'validation.ok', localId })
|
|
176
|
+
} finally {
|
|
177
|
+
release()
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
{ localId },
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (toAdd.length > 0) {
|
|
185
|
+
rt.applyInternal({ type: 'files.added', items: toAdd })
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ============================================================================
|
|
190
|
+
// pause -- abort inflight controller so runUpload routes to `paused`
|
|
191
|
+
// ============================================================================
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Side effect for `pause`.
|
|
195
|
+
*
|
|
196
|
+
* Aborts the inflight controller (if any) with `mode='pause'` so
|
|
197
|
+
* `runUpload` routes the abort to `applyPaused`. Queued items are reverted
|
|
198
|
+
* to `ready` by the reducer.
|
|
199
|
+
*
|
|
200
|
+
* @param rt Store runtime.
|
|
201
|
+
* @param localId Target item key.
|
|
202
|
+
* @template M Intent map keyed by strategy id.
|
|
203
|
+
* @template C Cursor map keyed by strategy id.
|
|
204
|
+
* @template P Purpose discriminator string.
|
|
205
|
+
* @template R Backend result payload.
|
|
206
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
207
|
+
*/
|
|
208
|
+
export function handlePause<
|
|
209
|
+
M extends Contracts.IntentMap,
|
|
210
|
+
C extends Contracts.CursorMap<M>,
|
|
211
|
+
P extends string,
|
|
212
|
+
R extends Contracts.IResultBase,
|
|
213
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string) {
|
|
214
|
+
const inflight = rt.inflightUploads.get(localId)
|
|
215
|
+
if (!inflight) return
|
|
216
|
+
inflight.mode = 'pause'
|
|
217
|
+
inflight.controller.abort('pause')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ============================================================================
|
|
221
|
+
// cancel -- mark mode, queue server abort for multipart, release all slots
|
|
222
|
+
// ============================================================================
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Cancel an upload.
|
|
226
|
+
*
|
|
227
|
+
* Marks the inflight upload's `mode = 'cancel'` so `runUpload`'s catch path
|
|
228
|
+
* routes to `canceled`, enqueues a best-effort server-side multipart abort,
|
|
229
|
+
* then releases all per-item bookkeeping with reason `'cancel'`.
|
|
230
|
+
*
|
|
231
|
+
* @param rt Store runtime.
|
|
232
|
+
* @param localId Target item key.
|
|
233
|
+
* @template M Intent map keyed by strategy id.
|
|
234
|
+
* @template C Cursor map keyed by strategy id.
|
|
235
|
+
* @template P Purpose discriminator string.
|
|
236
|
+
* @template R Backend result payload.
|
|
237
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
238
|
+
*/
|
|
239
|
+
export function handleCancel<
|
|
240
|
+
M extends Contracts.IntentMap,
|
|
241
|
+
C extends Contracts.CursorMap<M>,
|
|
242
|
+
P extends string,
|
|
243
|
+
R extends Contracts.IResultBase,
|
|
244
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string) {
|
|
245
|
+
const item = rt.state.items.get(localId)
|
|
246
|
+
|
|
247
|
+
// Idempotent: a redundant cancel must not enqueue another server abort.
|
|
248
|
+
if (item?.phase === 'canceled') return
|
|
249
|
+
|
|
250
|
+
const inflight = rt.inflightUploads.get(localId)
|
|
251
|
+
if (inflight) inflight.mode = 'cancel'
|
|
252
|
+
|
|
253
|
+
// Skip server multipart-abort once the session is past byte transfer; the
|
|
254
|
+
// backend is busy assembling and aborting would race with completion.
|
|
255
|
+
if (
|
|
256
|
+
item &&
|
|
257
|
+
item.phase !== 'completing' &&
|
|
258
|
+
item.phase !== 'completed' &&
|
|
259
|
+
hasIntent(item) &&
|
|
260
|
+
item.intent.strategy === 'multipart' &&
|
|
261
|
+
rt.opts.api.multipart?.abort
|
|
262
|
+
) {
|
|
263
|
+
const intent = item.intent
|
|
264
|
+
if (isMultipartIntent(intent)) {
|
|
265
|
+
rt.enqueueEffect(
|
|
266
|
+
async (signal) => {
|
|
267
|
+
try {
|
|
268
|
+
const abort = rt.opts.api.multipart?.abort
|
|
269
|
+
if (abort) await abort({ fileId: intent.fileId, uploadId: intent.uploadId }, { signal })
|
|
270
|
+
} catch {
|
|
271
|
+
// Server abort is opportunistic.
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{ localId },
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
releaseAllSlots(rt, localId, 'cancel')
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ============================================================================
|
|
283
|
+
// rebind -- attach a fresh File to a persisted `paused` item
|
|
284
|
+
// ============================================================================
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Pre-validate a `rebind` command and emit `rebind.ok` / `rebind.failed`.
|
|
288
|
+
*
|
|
289
|
+
* @param rt Store runtime.
|
|
290
|
+
* @param localId Target item key.
|
|
291
|
+
* @param file Fresh File to bind onto the persisted `paused` item.
|
|
292
|
+
* @template M Intent map keyed by strategy id.
|
|
293
|
+
* @template C Cursor map keyed by strategy id.
|
|
294
|
+
* @template P Purpose discriminator string.
|
|
295
|
+
* @template R Backend result payload.
|
|
296
|
+
* @returns True when the reducer should apply the command.
|
|
297
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
298
|
+
*/
|
|
299
|
+
export function handleRebind<
|
|
300
|
+
M extends Contracts.IntentMap,
|
|
301
|
+
C extends Contracts.CursorMap<M>,
|
|
302
|
+
P extends string,
|
|
303
|
+
R extends Contracts.IResultBase,
|
|
304
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string, file: File): boolean {
|
|
305
|
+
const item = rt.state.items.get(localId)
|
|
306
|
+
|
|
307
|
+
if (!item) {
|
|
308
|
+
rt.emitter.emit('rebind.failed', { localId, reason: { code: 'no_item' } })
|
|
309
|
+
return false
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (item.phase !== 'paused') {
|
|
313
|
+
rt.emitter.emit('rebind.failed', { localId, reason: { code: 'wrong_phase', phase: item.phase } })
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (item.file) {
|
|
318
|
+
rt.emitter.emit('rebind.failed', { localId, reason: { code: 'already_bound' } })
|
|
319
|
+
return false
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const next = fp(file)
|
|
323
|
+
if (!fingerprintMatches(next, item.fingerprint, { compareType: rt.opts.config.strictRebindType })) {
|
|
324
|
+
rt.emitter.emit('rebind.failed', {
|
|
325
|
+
localId,
|
|
326
|
+
reason: { code: 'fingerprint_mismatch', expected: item.fingerprint, got: next },
|
|
327
|
+
})
|
|
328
|
+
return false
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
rt.emitter.emit('rebind.ok', { localId })
|
|
332
|
+
return true
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ============================================================================
|
|
336
|
+
// createIntent -- call backend api.createIntent for items in creating_intent
|
|
337
|
+
// ============================================================================
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Call `api.createIntent` for an item in `creating_intent` and apply the result.
|
|
341
|
+
*
|
|
342
|
+
* Retries on retryable errors via `scheduleRetry`.
|
|
343
|
+
*
|
|
344
|
+
* @param rt Store runtime.
|
|
345
|
+
* @param localId Target item key.
|
|
346
|
+
* @param effectSignal Effect-pool watchdog signal.
|
|
347
|
+
* @template M Intent map keyed by strategy id.
|
|
348
|
+
* @template C Cursor map keyed by strategy id.
|
|
349
|
+
* @template P Purpose discriminator string.
|
|
350
|
+
* @template R Backend result payload.
|
|
351
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
352
|
+
*/
|
|
353
|
+
export async function createIntent<
|
|
354
|
+
M extends Contracts.IntentMap,
|
|
355
|
+
C extends Contracts.CursorMap<M>,
|
|
356
|
+
P extends string,
|
|
357
|
+
R extends Contracts.IResultBase,
|
|
358
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string, effectSignal?: AbortSignal) {
|
|
359
|
+
const item = rt.state.items.get(localId)
|
|
360
|
+
if (!item || item.phase !== 'creating_intent') return
|
|
361
|
+
if (rt.inflightIntents.has(localId)) return
|
|
362
|
+
|
|
363
|
+
const { controller, release } = acquireSlot(rt.inflightIntents, localId, effectSignal)
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
const intent = await rt.opts.api.createIntent(
|
|
367
|
+
{
|
|
368
|
+
purpose: item.purpose,
|
|
369
|
+
contentType: item.file.type || 'application/octet-stream',
|
|
370
|
+
size: item.file.size,
|
|
371
|
+
filename: item.file.name,
|
|
372
|
+
checksum: item.fingerprint.checksum,
|
|
373
|
+
},
|
|
374
|
+
{ signal: controller.signal },
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
// Item might have been canceled while intent was creating.
|
|
378
|
+
const current = rt.state.items.get(localId)
|
|
379
|
+
if (!current || current.phase !== 'creating_intent') return
|
|
380
|
+
|
|
381
|
+
const intentError = validateIntent(intent, intent.strategy, item.file.size)
|
|
382
|
+
if (intentError) {
|
|
383
|
+
const error: Contracts.Error = {
|
|
384
|
+
code: 'validation_failed',
|
|
385
|
+
message: `Invalid intent from backend: ${intentError.message}`,
|
|
386
|
+
cause: intentError,
|
|
387
|
+
retryable: false,
|
|
388
|
+
}
|
|
389
|
+
rt.applyInternal({ type: 'intent.failed', localId, error, retryable: false })
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
rt.applyInternal({ type: 'intent.ok', localId, intent: narrowIntent<M>(intent) })
|
|
394
|
+
} catch (err: unknown) {
|
|
395
|
+
if (controller.signal.aborted) return
|
|
396
|
+
|
|
397
|
+
const error = normalizeError(err, rt.opts.errorNormalizer, rt.opts.config.keepRawCause)
|
|
398
|
+
const errorWithContext = withErrorContext(
|
|
399
|
+
error,
|
|
400
|
+
rt.opts.config.errorContextInMessage,
|
|
401
|
+
() => `file: ${item.fingerprint.name}, size: ${item.file.size} bytes, purpose: ${item.purpose}`,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
const decision = retryDecision(rt.opts.config, { phase: 'intent', attempt: item.attempt, error: errorWithContext })
|
|
405
|
+
|
|
406
|
+
rt.applyInternal({ type: 'intent.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
407
|
+
|
|
408
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
409
|
+
scheduleRetry(rt, localId, decision.delayMs)
|
|
410
|
+
}
|
|
411
|
+
} finally {
|
|
412
|
+
release()
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Cast a `validateIntent`-passed value to its typed variant. */
|
|
417
|
+
function narrowIntent<M extends Contracts.IntentMap>(intent: unknown): Contracts.AnyIntent<M> {
|
|
418
|
+
return intent as Contracts.AnyIntent<M>
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ============================================================================
|
|
422
|
+
// runUpload -- drive an uploading item through its registered strategy
|
|
423
|
+
// ============================================================================
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Drive a single upload from `uploading` to a terminal phase via the registered strategy.
|
|
427
|
+
*
|
|
428
|
+
* Owns the per-item `inflightUploads` slot, releases it before emitting
|
|
429
|
+
* terminal transitions so the scheduler can start the next queued item in
|
|
430
|
+
* the same tick.
|
|
431
|
+
*
|
|
432
|
+
* @param rt Store runtime.
|
|
433
|
+
* @param localId Target item key.
|
|
434
|
+
* @template M Intent map keyed by strategy id.
|
|
435
|
+
* @template C Cursor map keyed by strategy id.
|
|
436
|
+
* @template P Purpose discriminator string.
|
|
437
|
+
* @template R Backend result payload.
|
|
438
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
439
|
+
*/
|
|
440
|
+
export async function runUpload<
|
|
441
|
+
M extends Contracts.IntentMap,
|
|
442
|
+
C extends Contracts.CursorMap<M>,
|
|
443
|
+
P extends string,
|
|
444
|
+
R extends Contracts.IResultBase,
|
|
445
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string) {
|
|
446
|
+
const item = rt.state.items.get(localId)
|
|
447
|
+
|
|
448
|
+
if (!item || item.phase !== 'uploading' || !hasIntent(item)) {
|
|
449
|
+
// Only clean an inflight that no other invocation has claimed yet.
|
|
450
|
+
const stale = rt.inflightUploads.get(localId)
|
|
451
|
+
if (stale && !stale.started) rt.inflightUploads.delete(localId)
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let inflight = rt.inflightUploads.get(localId)
|
|
456
|
+
if (!inflight) {
|
|
457
|
+
inflight = { controller: new AbortController(), mode: 'normal', started: false }
|
|
458
|
+
rt.inflightUploads.set(localId, inflight)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (inflight.started) return
|
|
462
|
+
inflight.started = true
|
|
463
|
+
|
|
464
|
+
const controller = inflight.controller
|
|
465
|
+
// Only delete the map entry if it is still the one we own.
|
|
466
|
+
const releaseOwnedSlot = () => {
|
|
467
|
+
if (rt.inflightUploads.get(localId) === inflight) rt.inflightUploads.delete(localId)
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
try {
|
|
471
|
+
// Terminal-state helpers release the slot before applyInternal so the
|
|
472
|
+
// scheduler sees an accurate active count when it fires synchronously.
|
|
473
|
+
const applyCanceled = () => {
|
|
474
|
+
const current = rt.state.items.get(localId)
|
|
475
|
+
if (!current || current.phase !== 'uploading') return
|
|
476
|
+
releaseOwnedSlot()
|
|
477
|
+
rt.applyInternal({ type: 'canceled', localId, canceledAt: Date.now() })
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const applyPaused = (cursor: Contracts.AnyCursor<C>) => {
|
|
481
|
+
const current = rt.state.items.get(localId)
|
|
482
|
+
if (!current || current.phase !== 'uploading') return
|
|
483
|
+
releaseOwnedSlot()
|
|
484
|
+
rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Aborted before strategy.start runs: route to the matching terminal state.
|
|
488
|
+
if (controller.signal.aborted) {
|
|
489
|
+
if (inflight.mode === 'pause') {
|
|
490
|
+
const cur = rt.state.items.get(localId)
|
|
491
|
+
const strategy = item.intent.strategy
|
|
492
|
+
const cursor: Contracts.AnyCursor<C> = cur && hasCursor(cur) ? cur.cursor : { strategy, value: undefined }
|
|
493
|
+
applyPaused(cursor)
|
|
494
|
+
return
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (inflight.mode === 'cancel') {
|
|
498
|
+
applyCanceled()
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// mode === 'normal' means something external aborted us (watchdog race,
|
|
503
|
+
// consumer reaching in). Surface as a non-retryable abort error so the
|
|
504
|
+
// cause is visible to telemetry rather than silently canceled.
|
|
505
|
+
const error: Contracts.Error = {
|
|
506
|
+
code: 'aborted',
|
|
507
|
+
message: 'Upload controller aborted before strategy start',
|
|
508
|
+
reason: 'unknown',
|
|
509
|
+
retryable: false,
|
|
510
|
+
}
|
|
511
|
+
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
const strategyId = item.intent.strategy
|
|
516
|
+
const strategy = rt.opts.strategies.get(strategyId)
|
|
517
|
+
if (!strategy) {
|
|
518
|
+
const error: Contracts.Error = {
|
|
519
|
+
code: 'strategy_missing',
|
|
520
|
+
message: `Strategy not found: ${strategyId}`,
|
|
521
|
+
strategy: String(strategyId),
|
|
522
|
+
retryable: false,
|
|
523
|
+
}
|
|
524
|
+
rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
|
|
525
|
+
return
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const progressThrottle = rt.opts.config.progressThrottleMs
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
await strategy.start({
|
|
532
|
+
file: item.file,
|
|
533
|
+
intent: item.intent,
|
|
534
|
+
signal: controller.signal,
|
|
535
|
+
transport: rt.opts.transport,
|
|
536
|
+
api: rt.opts.api,
|
|
537
|
+
|
|
538
|
+
readCursor: () => {
|
|
539
|
+
const cur = rt.state.items.get(localId)
|
|
540
|
+
if (!cur || !hasCursor(cur)) return undefined
|
|
541
|
+
if (cur.cursor.strategy !== strategyId) return undefined
|
|
542
|
+
return cur.cursor.value
|
|
543
|
+
},
|
|
544
|
+
|
|
545
|
+
persistCursor: (cursorValue) => {
|
|
546
|
+
// Bug guard: a strategy holds the `persistCursor` reference across
|
|
547
|
+
// its own async boundaries. Calling it post-abort used to flip
|
|
548
|
+
// `cursor.updated` through the reducer on an item the engine had
|
|
549
|
+
// already moved out of `uploading`. Reject after the abort so
|
|
550
|
+
// late writes are dropped at the source.
|
|
551
|
+
if (controller.signal.aborted) return
|
|
552
|
+
const tagged: Contracts.AnyCursor<C> = { strategy: strategyId, value: cursorValue }
|
|
553
|
+
rt.applyInternal({ type: 'cursor.updated', localId, cursor: tagged })
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
reportProgress: (p) => {
|
|
557
|
+
const now = Date.now()
|
|
558
|
+
// Always emit the terminal tick (100%); UIs jump from e.g. 87% to
|
|
559
|
+
// 'completing' if the throttle eats the final progress event.
|
|
560
|
+
const isFinal = p.totalBytes > 0 && p.uploadedBytes >= p.totalBytes
|
|
561
|
+
// Throttle state lives on `rt.progressThrottle` keyed by localId so
|
|
562
|
+
// it survives pause -> resume reschedules; without the carry-over,
|
|
563
|
+
// a rapid resume window would skip the throttle and re-emit on
|
|
564
|
+
// every tick.
|
|
565
|
+
const last = rt.progressThrottle.get(localId) ?? 0
|
|
566
|
+
if (!isFinal && now - last < progressThrottle) return
|
|
567
|
+
rt.applyInternal({
|
|
568
|
+
type: 'upload.progress',
|
|
569
|
+
localId,
|
|
570
|
+
uploadedBytes: p.uploadedBytes,
|
|
571
|
+
totalBytes: p.totalBytes,
|
|
572
|
+
})
|
|
573
|
+
rt.progressThrottle.set(localId, now)
|
|
574
|
+
},
|
|
575
|
+
})
|
|
576
|
+
|
|
577
|
+
// Strategy returned: bytes are on the server. Honor a pending pause
|
|
578
|
+
// only if the strategy persisted a cursor; otherwise treat as
|
|
579
|
+
// completion so a cancel/pause in the final tick does not discard
|
|
580
|
+
// backed-up work.
|
|
581
|
+
const ok = sampleAbortContext(rt, localId, controller, inflight)
|
|
582
|
+
if (ok.aborted && ok.mode === 'pause' && ok.cursor) {
|
|
583
|
+
applyPaused(ok.cursor)
|
|
584
|
+
return
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
rt.applyInternal({ type: 'upload.ok', localId })
|
|
588
|
+
} catch (err: unknown) {
|
|
589
|
+
const errCtx = sampleAbortContext(rt, localId, controller, inflight)
|
|
590
|
+
|
|
591
|
+
if (errCtx.aborted) {
|
|
592
|
+
if (errCtx.mode === 'pause' && errCtx.cursor) {
|
|
593
|
+
applyPaused(errCtx.cursor)
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
// pause-without-cursor, cancel, or normal abort: terminal cancel.
|
|
597
|
+
applyCanceled()
|
|
598
|
+
return
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const error = normalizeError(err, rt.opts.errorNormalizer, rt.opts.config.keepRawCause)
|
|
602
|
+
const itemForContext = rt.state.items.get(localId)
|
|
603
|
+
const errorWithContext = itemForContext
|
|
604
|
+
? withErrorContext(error, rt.opts.config.errorContextInMessage, () => {
|
|
605
|
+
const fileSize = hasFile(itemForContext) ? itemForContext.file.size : 0
|
|
606
|
+
return `file: ${itemForContext.fingerprint.name}, size: ${fileSize} bytes, phase: ${itemForContext.phase}`
|
|
607
|
+
})
|
|
608
|
+
: error
|
|
609
|
+
const decision = retryDecision(rt.opts.config, {
|
|
610
|
+
phase: 'upload',
|
|
611
|
+
attempt: getAttempt(itemForContext),
|
|
612
|
+
error: errorWithContext,
|
|
613
|
+
})
|
|
614
|
+
rt.applyInternal({ type: 'upload.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
615
|
+
|
|
616
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
617
|
+
scheduleRetry(rt, localId, decision.delayMs)
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
} finally {
|
|
621
|
+
releaseOwnedSlot()
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Synchronously sample the abort context as a single tuple. Done in one
|
|
627
|
+
* block so a `cursor.updated` event landing between reads cannot flip a
|
|
628
|
+
* pause-without-cursor into a pause-with-stale-cursor or vice versa.
|
|
629
|
+
*/
|
|
630
|
+
function sampleAbortContext<
|
|
631
|
+
M extends Contracts.IntentMap,
|
|
632
|
+
C extends Contracts.CursorMap<M>,
|
|
633
|
+
P extends string,
|
|
634
|
+
R extends Contracts.IResultBase,
|
|
635
|
+
>(
|
|
636
|
+
rt: Store.IRuntime<M, C, P, R>,
|
|
637
|
+
localId: string,
|
|
638
|
+
controller: AbortController,
|
|
639
|
+
inflight: { mode: Store.InflightMode } | undefined,
|
|
640
|
+
): { aborted: boolean; mode: Store.InflightMode | undefined; cursor: Contracts.AnyCursor<C> | undefined } {
|
|
641
|
+
const aborted = controller.signal.aborted
|
|
642
|
+
const mode = inflight?.mode
|
|
643
|
+
if (!aborted) return { aborted: false, mode, cursor: undefined }
|
|
644
|
+
const item = rt.state.items.get(localId)
|
|
645
|
+
const cursor = item && hasCursor(item) ? item.cursor : undefined
|
|
646
|
+
return { aborted: true, mode, cursor }
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ============================================================================
|
|
650
|
+
// finalizeUpload -- commit a completing item via api.complete
|
|
651
|
+
// ============================================================================
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Call `api.complete` to commit a finished upload.
|
|
655
|
+
*
|
|
656
|
+
* Strategy-specific commit logic (multipart completeMultipart, tus HEAD,
|
|
657
|
+
* etc.) belongs in the strategy.
|
|
658
|
+
*
|
|
659
|
+
* @param rt Store runtime.
|
|
660
|
+
* @param localId Target item key.
|
|
661
|
+
* @param effectSignal Effect-pool watchdog signal.
|
|
662
|
+
* @template M Intent map keyed by strategy id.
|
|
663
|
+
* @template C Cursor map keyed by strategy id.
|
|
664
|
+
* @template P Purpose discriminator string.
|
|
665
|
+
* @template R Backend result payload.
|
|
666
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
667
|
+
*/
|
|
668
|
+
export async function finalizeUpload<
|
|
669
|
+
M extends Contracts.IntentMap,
|
|
670
|
+
C extends Contracts.CursorMap<M>,
|
|
671
|
+
P extends string,
|
|
672
|
+
R extends Contracts.IResultBase,
|
|
673
|
+
>(rt: Store.IRuntime<M, C, P, R>, localId: string, effectSignal?: AbortSignal) {
|
|
674
|
+
const item = rt.state.items.get(localId)
|
|
675
|
+
if (!item || item.phase !== 'completing') return
|
|
676
|
+
if (rt.inflightCompletes.has(localId)) return
|
|
677
|
+
|
|
678
|
+
const { controller, release } = acquireSlot(rt.inflightCompletes, localId, effectSignal)
|
|
679
|
+
|
|
680
|
+
try {
|
|
681
|
+
const result = await rt.opts.api.complete({ fileId: item.intent.fileId }, { signal: controller.signal })
|
|
682
|
+
|
|
683
|
+
const current = rt.state.items.get(localId)
|
|
684
|
+
if (!current || current.phase !== 'completing') return
|
|
685
|
+
|
|
686
|
+
rt.applyInternal({ type: 'complete.ok', localId, result })
|
|
687
|
+
} catch (err: unknown) {
|
|
688
|
+
if (controller.signal.aborted) return
|
|
689
|
+
|
|
690
|
+
const error = normalizeError(err, rt.opts.errorNormalizer, rt.opts.config.keepRawCause)
|
|
691
|
+
|
|
692
|
+
const itemForContext = rt.state.items.get(localId)
|
|
693
|
+
const errorWithContext = itemForContext
|
|
694
|
+
? withErrorContext(error, rt.opts.config.errorContextInMessage, () => {
|
|
695
|
+
const fileId = hasIntent(itemForContext) ? itemForContext.intent.fileId : 'unknown'
|
|
696
|
+
return `file: ${itemForContext.fingerprint.name}, fileId: ${fileId}`
|
|
697
|
+
})
|
|
698
|
+
: error
|
|
699
|
+
|
|
700
|
+
const decision = retryDecision(rt.opts.config, {
|
|
701
|
+
phase: 'complete',
|
|
702
|
+
attempt: getAttempt(itemForContext),
|
|
703
|
+
error: errorWithContext,
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
rt.applyInternal({ type: 'complete.failed', localId, error: errorWithContext, retryable: decision.retryable })
|
|
707
|
+
|
|
708
|
+
if (decision.retryable && decision.delayMs !== undefined) {
|
|
709
|
+
scheduleRetry(rt, localId, decision.delayMs)
|
|
710
|
+
}
|
|
711
|
+
} finally {
|
|
712
|
+
release()
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// ============================================================================
|
|
717
|
+
// cleanupOldItems -- evict terminal items based on TTL + maxItems caps
|
|
718
|
+
// ============================================================================
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Evict terminal items based on `completedItemTTL` and `maxItems`.
|
|
722
|
+
*
|
|
723
|
+
* Returns a new state when items were removed, otherwise `null`. Runs on
|
|
724
|
+
* every dispatch and short-circuits when no caps apply. Terminal phases
|
|
725
|
+
* that age out: `completed`, `canceled`, and `error` with
|
|
726
|
+
* `retryable === false`. Retryable errors are kept so a user-driven retry
|
|
727
|
+
* still has the item to act on; non-retryable errors are final, like a
|
|
728
|
+
* canceled item.
|
|
729
|
+
*
|
|
730
|
+
* @param rt Store runtime (used for memoization and config).
|
|
731
|
+
* @param state Current reducer state to scan.
|
|
732
|
+
* @template M Intent map keyed by strategy id.
|
|
733
|
+
* @template C Cursor map keyed by strategy id.
|
|
734
|
+
* @template P Purpose discriminator string.
|
|
735
|
+
* @template R Backend result payload.
|
|
736
|
+
* @returns New state when items were evicted, or `null` when unchanged.
|
|
737
|
+
* @author wildduck2 <https://github.com/wildduck2>
|
|
738
|
+
*/
|
|
739
|
+
export function cleanupOldItems<
|
|
740
|
+
M extends Contracts.IntentMap,
|
|
741
|
+
C extends Contracts.CursorMap<M>,
|
|
742
|
+
P extends string,
|
|
743
|
+
R extends Contracts.IResultBase,
|
|
744
|
+
>(rt: Store.IRuntime<M, C, P, R>, state: Reducer.IState<M, C, P, R>): Reducer.IState<M, C, P, R> | null {
|
|
745
|
+
const maxItems = rt.opts.config?.maxItems
|
|
746
|
+
const completedTTL = rt.opts.config?.completedItemTTL
|
|
747
|
+
|
|
748
|
+
const noTTL = completedTTL === undefined
|
|
749
|
+
const noMax = maxItems === undefined || maxItems === null
|
|
750
|
+
if (noTTL && noMax) return null
|
|
751
|
+
if (noTTL && !noMax && state.items.size <= (maxItems as number)) return null
|
|
752
|
+
|
|
753
|
+
const now = Date.now()
|
|
754
|
+
// Short-circuit when nothing has changed since the last cleanup AND no
|
|
755
|
+
// eligible terminal item has aged past its TTL. The TTL deadline is the
|
|
756
|
+
// earliest `terminalTs + completedTTL` across all terminal items; the
|
|
757
|
+
// walk is skipped until wall-clock reaches it.
|
|
758
|
+
if (rt.cleanupCheckedState === state && now < rt.cleanupNextDeadline) return null
|
|
759
|
+
|
|
760
|
+
const toRemove: string[] = []
|
|
761
|
+
// Direct Map iteration: avoids Array.from allocation when the path
|
|
762
|
+
// short-circuits in the TTL-only branch (the common case).
|
|
763
|
+
let nextDeadline = Number.POSITIVE_INFINITY
|
|
764
|
+
|
|
765
|
+
if (completedTTL !== undefined) {
|
|
766
|
+
for (const [localId, item] of state.items) {
|
|
767
|
+
const ts = terminalTimestamp(item)
|
|
768
|
+
if (ts === null) continue
|
|
769
|
+
const ageDeadline = ts + completedTTL
|
|
770
|
+
if (now > ageDeadline) toRemove.push(localId)
|
|
771
|
+
else if (ageDeadline < nextDeadline) nextDeadline = ageDeadline
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const removed = new Set(toRemove)
|
|
776
|
+
if (!noMax && state.items.size - removed.size > (maxItems as number)) {
|
|
777
|
+
// Allocate only on the over-cap branch.
|
|
778
|
+
const remaining: Array<[string, Engine.Item<M, C, P, R>, number]> = []
|
|
779
|
+
for (const [id, item] of state.items) {
|
|
780
|
+
if (removed.has(id)) continue
|
|
781
|
+
const ts = terminalTimestamp(item)
|
|
782
|
+
if (ts !== null) remaining.push([id, item, ts])
|
|
783
|
+
}
|
|
784
|
+
remaining.sort((a, b) => a[2] - b[2])
|
|
785
|
+
const overCap = state.items.size - removed.size - (maxItems as number)
|
|
786
|
+
for (let i = 0; i < overCap && i < remaining.length; i++) toRemove.push(remaining[i][0])
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (toRemove.length === 0) {
|
|
790
|
+
rt.cleanupCheckedState = state
|
|
791
|
+
rt.cleanupNextDeadline = nextDeadline
|
|
792
|
+
return null
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const nextItems = new Map(state.items)
|
|
796
|
+
for (const localId of toRemove) nextItems.delete(localId)
|
|
797
|
+
// Cleanup mutated state -- invalidate the memo; next pass walks from the
|
|
798
|
+
// fresh state ref.
|
|
799
|
+
rt.cleanupCheckedState = null
|
|
800
|
+
rt.cleanupNextDeadline = Number.POSITIVE_INFINITY
|
|
801
|
+
|
|
802
|
+
return { ...state, items: nextItems }
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Return the relevant terminal timestamp for an item, or `null` if the item
|
|
807
|
+
* is not eligible for eviction (active or retryable error).
|
|
808
|
+
*/
|
|
809
|
+
function terminalTimestamp<
|
|
810
|
+
M extends Contracts.IntentMap,
|
|
811
|
+
C extends Contracts.CursorMap<M>,
|
|
812
|
+
P extends string,
|
|
813
|
+
R extends Contracts.IResultBase,
|
|
814
|
+
>(item: Engine.Item<M, C, P, R>): number | null {
|
|
815
|
+
if (item.phase === 'completed' && typeof item.completedAt === 'number') return item.completedAt
|
|
816
|
+
if (item.phase === 'canceled' && typeof item.canceledAt === 'number') return item.canceledAt
|
|
817
|
+
if (item.phase === 'error' && item.retryable === false && typeof item.failedAt === 'number') return item.failedAt
|
|
818
|
+
return null
|
|
819
|
+
}
|