@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.
Files changed (42) hide show
  1. package/README.md +320 -0
  2. package/dist/core/index.d.ts +1 -1
  3. package/dist/core/index.js +1 -1
  4. package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
  5. package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/react/index.d.ts +8 -47
  9. package/dist/react/index.js +1 -1
  10. package/dist/strategies/index.d.ts +5 -5
  11. package/package.json +1 -1
  12. package/src/__test__/effect-pool.test.ts +5 -0
  13. package/src/__test__/emitter.test.ts +85 -0
  14. package/src/__test__/fingerprint.test.ts +49 -0
  15. package/src/__test__/guards.test.ts +30 -0
  16. package/src/__test__/id.test.ts +14 -0
  17. package/src/__test__/persistence.test.ts +156 -0
  18. package/src/__test__/store-integration.test.ts +5 -0
  19. package/src/__test__/store-libs.test.ts +138 -0
  20. package/src/__test__/transport-libs.test.ts +42 -0
  21. package/src/__test__/transport.test.ts +182 -0
  22. package/src/core/client/client.ts +50 -0
  23. package/src/core/client/client.types.ts +184 -0
  24. package/src/core/contracts/contracts.types.ts +179 -0
  25. package/src/core/contracts/transport/transport.libs.ts +7 -32
  26. package/src/core/contracts/transport/transport.ts +5 -7
  27. package/src/core/engine/engine.types.ts +364 -0
  28. package/src/core/engine/internal-events.types.ts +3 -23
  29. package/src/core/engine/reducer.ts +5 -11
  30. package/src/core/engine/store/handlers/add-file.ts +6 -21
  31. package/src/core/engine/store/handlers/run-upload.ts +7 -7
  32. package/src/core/engine/store/handlers.ts +819 -0
  33. package/src/core/engine/store/store.libs.ts +17 -57
  34. package/src/core/engine/store/store.runtime.ts +10 -20
  35. package/src/core/engine/validation/file.ts +1 -1
  36. package/src/core/engine/validation.ts +216 -0
  37. package/src/core/utils/emitter/emitter.ts +143 -0
  38. package/src/react/upload-provider.tsx +7 -58
  39. package/src/react/uploader.types.ts +83 -0
  40. package/src/react/use-uploader.tsx +2 -5
  41. package/src/strategies/multipart/index.ts +9 -12
  42. 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
+ }