@gentleduck/upload 0.2.5 → 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
@@ -110,17 +110,16 @@ function xhrRequest(args: {
110
110
  }
111
111
 
112
112
  /**
113
- * Creates a browser-native XHR transport.
114
- * We use XMLHttpRequest instead of fetch because fetch does not yet support
115
- * upload progress events in a standard cross-browser way.
113
+ * Browser-native XHR transport. Uses `XMLHttpRequest` over `fetch` because
114
+ * `fetch` lacks cross-browser upload-progress events.
116
115
  */
117
116
  export function createXHRTransport(): UploadTransport & {
118
- // Optional: for tus strategies (PATCH)
117
+ /** Optional PATCH for tus-style strategies. */
119
118
  patch?: (args: XhrCommonArgs & { body: Blob | ArrayBuffer }) => Promise<{ headers: Record<string, string> }>
120
119
  } {
121
120
  return {
122
121
  async postForm(args) {
123
- // Note: presigned POST is usually absolute already (MinIO/S3 URL)
122
+ // Presigned POST URLs are absolute (MinIO/S3).
124
123
  const form = new FormData()
125
124
  for (const [k, v] of Object.entries(args.fields)) form.append(k, v)
126
125
  form.append('file', args.file, args.filename ?? 'file')
@@ -146,11 +145,10 @@ export function createXHRTransport(): UploadTransport & {
146
145
  onProgress: args.onProgress,
147
146
  })
148
147
 
149
- // multipart needs ETag from each part
148
+ // S3 multipart needs the ETag from each part response.
150
149
  return { headers: out.headers, etag: out.etag }
151
150
  },
152
151
 
153
- // Optional for tus strategy (PATCH)
154
152
  async patch(args) {
155
153
  const out = await xhrRequest({
156
154
  method: 'PATCH',
@@ -0,0 +1,364 @@
1
+ import type { Contracts } from '../contracts'
2
+
3
+ /**
4
+ * Engine-level type surface.
5
+ *
6
+ * @author wildduck2 <https://github.com/wildduck2>
7
+ */
8
+ export namespace Engine {
9
+ // -------------------- Progress --------------------
10
+
11
+ /**
12
+ * Upload progress snapshot with `pct` clamped to 0..100.
13
+ *
14
+ * @author wildduck2 <https://github.com/wildduck2>
15
+ */
16
+ export interface IUploadProgress {
17
+ /** Bytes uploaded so far. */
18
+ uploadedBytes: number
19
+ /** Total bytes to upload. */
20
+ totalBytes: number
21
+ /** Percent complete (0..100). */
22
+ pct: number
23
+ }
24
+
25
+ // -------------------- Outcome --------------------
26
+
27
+ /**
28
+ * How a `completed` item finished.
29
+ *
30
+ * @author wildduck2 <https://github.com/wildduck2>
31
+ */
32
+ export type CompletionKind = 'upload' | 'dedupe'
33
+
34
+ /**
35
+ * Terminal outcome returned by `Store.IUploadStore.waitFor`.
36
+ *
37
+ * @template R Result type carried on successful completion.
38
+ * @author wildduck2 <https://github.com/wildduck2>
39
+ */
40
+ export type Outcome<R extends Contracts.IResultBase> =
41
+ | { localId: string; status: 'completed'; completedBy: CompletionKind; result: R }
42
+ | { localId: string; status: 'error'; error: Contracts.Error }
43
+ | { localId: string; status: 'canceled' }
44
+ | {
45
+ localId: string
46
+ status: 'missing'
47
+ /**
48
+ * Why the id is missing. Required so consumers exhaustively
49
+ * handle every cause via switch.
50
+ * - `'removed'`: explicit `dispatch({ type: 'remove' })`.
51
+ * - `'evicted'`: cleanup pass (`maxItems` / `completedItemTTL`).
52
+ * - `'never-existed'`: the id was never registered in this store.
53
+ * - `'destroyed'`: `store.destroy()` ran while the wait was pending.
54
+ */
55
+ reason: 'removed' | 'evicted' | 'never-existed' | 'destroyed'
56
+ }
57
+
58
+ // -------------------- Commands --------------------
59
+
60
+ /**
61
+ * Public commands accepted by `store.dispatch`.
62
+ *
63
+ * @template P Purpose discriminator string.
64
+ * @author wildduck2 <https://github.com/wildduck2>
65
+ */
66
+ export type Command<P extends string> =
67
+ | { type: 'addFiles'; files: File[]; purpose: P }
68
+ | { type: 'start'; localId: string }
69
+ | { type: 'startAll'; purpose?: P }
70
+ | { type: 'pause'; localId: string }
71
+ | { type: 'pauseAll'; purpose?: P }
72
+ | { type: 'resume'; localId: string }
73
+ | { type: 'cancel'; localId: string }
74
+ | { type: 'cancelAll'; purpose?: P }
75
+ | { type: 'retry'; localId: string }
76
+ | { type: 'rebind'; localId: string; file: File }
77
+ | { type: 'remove'; localId: string }
78
+
79
+ // -------------------- Phases / Items --------------------
80
+
81
+ /**
82
+ * Every upload phase; the `phase` discriminator of {@link Engine.Item}.
83
+ *
84
+ * @author wildduck2 <https://github.com/wildduck2>
85
+ */
86
+ export type Phase =
87
+ | 'validating'
88
+ | 'creating_intent'
89
+ | 'ready'
90
+ | 'queued'
91
+ | 'uploading'
92
+ | 'paused'
93
+ | 'completing'
94
+ | 'completed'
95
+ | 'error'
96
+ | 'canceled'
97
+
98
+ /**
99
+ * Enum-style const map of every {@link Phase} value.
100
+ *
101
+ * Reading `Phases.completing` matches the string literal at compile time
102
+ * AND gives consumers a single import point instead of N magic-string
103
+ * checks. Use whichever style fits the call site.
104
+ *
105
+ * @author wildduck2 <https://github.com/wildduck2>
106
+ */
107
+ export const Phases = {
108
+ validating: 'validating',
109
+ creating_intent: 'creating_intent',
110
+ ready: 'ready',
111
+ queued: 'queued',
112
+ uploading: 'uploading',
113
+ paused: 'paused',
114
+ completing: 'completing',
115
+ completed: 'completed',
116
+ error: 'error',
117
+ canceled: 'canceled',
118
+ } as const satisfies Record<Phase, Phase>
119
+
120
+ /**
121
+ * Discriminated union on `phase` describing a single upload's state.
122
+ *
123
+ * @template M Intent map keyed by strategy id.
124
+ * @template C Cursor map keyed by strategy id.
125
+ * @template P Purpose discriminator string.
126
+ * @template R Backend result payload.
127
+ * @author wildduck2 <https://github.com/wildduck2>
128
+ */
129
+ export type Item<
130
+ M extends Contracts.IntentMap,
131
+ C extends Contracts.CursorMap<M>,
132
+ P extends string,
133
+ R extends Contracts.IResultBase = Contracts.IResultBase,
134
+ > =
135
+ | {
136
+ phase: 'validating'
137
+ localId: string
138
+ purpose: P
139
+ fingerprint: Contracts.IFileFingerprint
140
+ file: File
141
+ createdAt: number
142
+ }
143
+ | {
144
+ phase: 'creating_intent'
145
+ localId: string
146
+ purpose: P
147
+ fingerprint: Contracts.IFileFingerprint
148
+ file: File
149
+ attempt: number
150
+ createdAt: number
151
+ }
152
+ | {
153
+ phase: 'ready'
154
+ localId: string
155
+ purpose: P
156
+ fingerprint: Contracts.IFileFingerprint
157
+ file: File
158
+ intent: Contracts.AnyIntent<M>
159
+ createdAt: number
160
+ cursor?: Contracts.AnyCursor<C>
161
+ progress?: IUploadProgress
162
+ attempt?: number
163
+ }
164
+ | {
165
+ phase: 'queued'
166
+ localId: string
167
+ purpose: P
168
+ fingerprint: Contracts.IFileFingerprint
169
+ file: File
170
+ intent: Contracts.AnyIntent<M>
171
+ requestedAt: number
172
+ createdAt: number
173
+ cursor?: Contracts.AnyCursor<C>
174
+ progress?: IUploadProgress
175
+ attempt?: number
176
+ }
177
+ | {
178
+ phase: 'uploading'
179
+ localId: string
180
+ purpose: P
181
+ fingerprint: Contracts.IFileFingerprint
182
+ file: File
183
+ intent: Contracts.AnyIntent<M>
184
+ startedAt: number
185
+ progress: IUploadProgress
186
+ createdAt: number
187
+ cursor?: Contracts.AnyCursor<C>
188
+ attempt?: number
189
+ }
190
+ | {
191
+ phase: 'paused'
192
+ localId: string
193
+ purpose: P
194
+ fingerprint: Contracts.IFileFingerprint
195
+ intent: Contracts.AnyIntent<M>
196
+ cursor: Contracts.AnyCursor<C>
197
+ progress: IUploadProgress
198
+ pausedAt: number
199
+ createdAt: number
200
+ file?: File
201
+ attempt?: number
202
+ }
203
+ | {
204
+ phase: 'completing'
205
+ localId: string
206
+ purpose: P
207
+ fingerprint: Contracts.IFileFingerprint
208
+ file: File
209
+ intent: Contracts.AnyIntent<M>
210
+ progress: IUploadProgress
211
+ completingAt: number
212
+ createdAt: number
213
+ attempt?: number
214
+ }
215
+ | {
216
+ phase: 'completed'
217
+ localId: string
218
+ file?: File
219
+ purpose: P
220
+ fingerprint: Contracts.IFileFingerprint
221
+ intent?: Contracts.AnyIntent<M>
222
+ completedBy: CompletionKind
223
+ result: R
224
+ completedAt: number
225
+ createdAt: number
226
+ attempt?: number
227
+ }
228
+ | {
229
+ phase: 'error'
230
+ localId: string
231
+ purpose: P
232
+ fingerprint: Contracts.IFileFingerprint
233
+ error: Contracts.Error
234
+ retryable: boolean
235
+ attempt: number
236
+ failedAt: number
237
+ createdAt: number
238
+ file?: File
239
+ intent?: Contracts.AnyIntent<M>
240
+ cursor?: Contracts.AnyCursor<C>
241
+ progress?: IUploadProgress
242
+ }
243
+ | {
244
+ phase: 'canceled'
245
+ localId: string
246
+ purpose: P
247
+ fingerprint: Contracts.IFileFingerprint
248
+ canceledAt: number
249
+ createdAt: number
250
+ file?: File
251
+ intent?: Contracts.AnyIntent<M>
252
+ cursor?: Contracts.AnyCursor<C>
253
+ progress?: IUploadProgress
254
+ attempt?: number
255
+ }
256
+
257
+ // -------------------- Internal events --------------------
258
+
259
+ /**
260
+ * Events emitted by effects and consumed by the reducer. Distinct from the
261
+ * public event surface -- these drive state transitions.
262
+ *
263
+ * @template M Intent map keyed by strategy id.
264
+ * @template C Cursor map keyed by strategy id.
265
+ * @template P Purpose discriminator string.
266
+ * @template R Backend result payload.
267
+ * @author wildduck2 <https://github.com/wildduck2>
268
+ */
269
+ export type InternalEvent<
270
+ M extends Contracts.IntentMap,
271
+ C extends Contracts.CursorMap<M>,
272
+ P extends string,
273
+ R extends Contracts.IResultBase = Contracts.IResultBase,
274
+ > =
275
+ | {
276
+ type: 'files.added'
277
+ items: Array<{
278
+ localId: string
279
+ purpose: P
280
+ file: File
281
+ fingerprint: Contracts.IFileFingerprint
282
+ createdAt: number
283
+ }>
284
+ }
285
+ | { type: 'fingerprint.updated'; localId: string; fingerprint: Contracts.IFileFingerprint }
286
+ | { type: 'validation.ok'; localId: string }
287
+ | { type: 'validation.failed'; localId: string; reason: Contracts.RejectReason }
288
+ | { type: 'intent.ok'; localId: string; intent: Contracts.AnyIntent<M> }
289
+ | { type: 'intent.failed'; localId: string; error: Contracts.Error; retryable: boolean }
290
+ | { type: 'upload.begin'; localId: string; startedAt: number }
291
+ | { type: 'upload.progress'; localId: string; uploadedBytes: number; totalBytes: number }
292
+ | { type: 'cursor.updated'; localId: string; cursor: Contracts.AnyCursor<C> }
293
+ | { type: 'upload.ok'; localId: string }
294
+ | { type: 'upload.failed'; localId: string; error: Contracts.Error; retryable: boolean }
295
+ | { type: 'dedupe.ok'; localId: string; result: R }
296
+ | { type: 'complete.ok'; localId: string; result: R }
297
+ | { type: 'complete.failed'; localId: string; error: Contracts.Error; retryable: boolean }
298
+ | { type: 'paused'; localId: string; cursor: Contracts.AnyCursor<C>; pausedAt: number }
299
+ | { type: 'canceled'; localId: string; canceledAt: number }
300
+
301
+ // -------------------- Public event map --------------------
302
+
303
+ /**
304
+ * Public event map emitted to consumers via `store.on(type, cb)`.
305
+ *
306
+ * @template M Intent map keyed by strategy id.
307
+ * @template C Cursor map keyed by strategy id.
308
+ * @template P Purpose discriminator string.
309
+ * @template R Backend result payload.
310
+ * @author wildduck2 <https://github.com/wildduck2>
311
+ */
312
+ export type EventMap<
313
+ M extends Contracts.IntentMap,
314
+ C extends Contracts.CursorMap<M>,
315
+ P extends string,
316
+ R extends Contracts.IResultBase = Contracts.IResultBase,
317
+ > = {
318
+ 'file.added': { localId: string; purpose: P; file: File; fingerprint: Contracts.IFileFingerprint }
319
+ 'file.rejected': { file: File; reason: Contracts.RejectReason }
320
+
321
+ 'validation.ok': { localId: string }
322
+ 'validation.failed': { localId: string; reason: Contracts.RejectReason }
323
+
324
+ 'intent.creating': { localId: string }
325
+ 'intent.created': { localId: string; intent: M[keyof M] }
326
+ 'intent.failed': { localId: string; error: Contracts.Error; retryable: boolean }
327
+
328
+ 'upload.queued': { localId: string }
329
+ 'upload.resumed': { localId: string }
330
+ 'upload.started': { localId: string }
331
+ 'upload.progress': { localId: string; pct: number; uploadedBytes: number; totalBytes: number }
332
+ 'upload.cursor': { localId: string; cursor: Contracts.AnyCursor<C> }
333
+ 'upload.paused': { localId: string; cursor: Contracts.AnyCursor<C> }
334
+ 'upload.canceled': { localId: string }
335
+
336
+ 'upload.completing': { localId: string }
337
+ 'upload.completed': { localId: string; result: R; completedBy: CompletionKind }
338
+
339
+ 'upload.error': { localId: string; error: Contracts.Error; retryable: boolean }
340
+
341
+ /**
342
+ * Emitted whenever an item leaves state. `reason: 'user'` for an
343
+ * explicit `dispatch({ type: 'remove' })`; `reason: 'cleanup'` for
344
+ * an automatic eviction by `completedItemTTL` / `maxItems`. Lets
345
+ * consumers (waitFor, UI listeners, telemetry) react without polling
346
+ * the snapshot.
347
+ */
348
+ 'upload.removed': { localId: string; reason: 'user' | 'cleanup' }
349
+
350
+ 'rebind.ok': { localId: string }
351
+ 'rebind.failed': { localId: string; reason: RebindReason }
352
+ }
353
+
354
+ /**
355
+ * Why a `rebind` command did not bind a file.
356
+ *
357
+ * @author wildduck2 <https://github.com/wildduck2>
358
+ */
359
+ export type RebindReason =
360
+ | { code: 'no_item' }
361
+ | { code: 'wrong_phase'; phase: string }
362
+ | { code: 'already_bound' }
363
+ | { code: 'fingerprint_mismatch'; expected: Contracts.IFileFingerprint; got: Contracts.IFileFingerprint }
364
+ }
@@ -13,11 +13,7 @@ import type { UploadProgress } from './progress.types'
13
13
 
14
14
  /**
15
15
  * Internal events emitted by effects and consumed by the reducer.
16
- *
17
- * These are not the same as public events. They are designed for correctness and state transitions.
18
- *
19
- * @typeParam M - Intent map
20
- * @typeParam C - Cursor map
16
+ * Distinct from the public event surface — these drive state transitions.
21
17
  */
22
18
  export type InternalEvent<
23
19
  M extends IntentMap,
@@ -47,15 +43,7 @@ export type InternalEvent<
47
43
  /** Internal completion for cancel requests. */
48
44
  | { type: 'canceled'; localId: string; canceledAt: number }
49
45
 
50
- // ============================================================================
51
- // UPLOAD ITEM (STATE MACHINE)
52
- // ============================================================================
53
-
54
- /**
55
- * All possible upload phases.
56
- *
57
- * Useful for constraints and UI filtering.
58
- */
46
+ /** Every upload phase; the `phase` discriminator of {@link UploadItem}. */
59
47
  export type UploadPhase =
60
48
  | 'validating'
61
49
  | 'creating_intent'
@@ -68,15 +56,7 @@ export type UploadPhase =
68
56
  | 'error'
69
57
  | 'canceled'
70
58
 
71
- /**
72
- * State of a single upload item.
73
- *
74
- * This is a discriminated union on `phase`.
75
- *
76
- * @typeParam M - Intent map
77
- * @typeParam C - Cursor map
78
- * @typeParam P - Purpose union
79
- */
59
+ /** Discriminated union on `phase` describing a single upload's state. */
80
60
  export type UploadItem<
81
61
  M extends IntentMap,
82
62
  C extends CursorMap<M>,
@@ -30,12 +30,11 @@ export function createReducer<
30
30
 
31
31
  const now = Date.now()
32
32
 
33
- // Type guard: Commands don't have dots in their type, internal events do (e.g., 'intent.ok')
33
+ // Commands have no dots in their `type`; internal events do (e.g. 'intent.ok').
34
34
  const isCommand = (e: UploadCommand<P> | InternalEvent<M, C, P, R>): e is UploadCommand<P> => {
35
35
  return 'type' in e && typeof e.type === 'string' && !e.type.includes('.')
36
36
  }
37
37
 
38
- // Command handling
39
38
  if (isCommand(event)) {
40
39
  switch (event.type) {
41
40
  case 'addFiles': {
@@ -75,15 +74,14 @@ export function createReducer<
75
74
  const item = items.get(event.localId)
76
75
  if (!item) break
77
76
 
78
- // If it's queued but not started, revert back to ready.
77
+ // Queued-but-not-started: revert to ready. Uploading: store aborts and
78
+ // emits 'paused' internally to move the item.
79
79
  if (item.phase === 'queued') {
80
80
  set(event.localId, {
81
81
  ...item,
82
82
  phase: 'ready',
83
83
  })
84
84
  }
85
-
86
- // If it's uploading, store will abort and an internal 'paused' event will move it.
87
85
  break
88
86
  }
89
87
 
@@ -103,7 +101,7 @@ export function createReducer<
103
101
  if (!item.file) break
104
102
 
105
103
  if (item.intent) {
106
- // If we have progress and intent, we were in completing phase - retry completing
104
+ // Progress at 100% with intent means we failed in 'completing' — retry that phase.
107
105
  if (item.progress && item.progress.pct === 100) {
108
106
  set(event.localId, {
109
107
  ...item,
@@ -114,7 +112,6 @@ export function createReducer<
114
112
  completingAt: now,
115
113
  })
116
114
  } else {
117
- // Otherwise, retry from ready phase
118
115
  set(event.localId, {
119
116
  ...item,
120
117
  file: item.file,
@@ -123,7 +120,7 @@ export function createReducer<
123
120
  })
124
121
  }
125
122
  } else {
126
- // For intent creation failures, increment attempt on retry
123
+ // Intent-creation failure path; bump attempt counter.
127
124
  set(event.localId, {
128
125
  ...item,
129
126
  phase: 'creating_intent',
@@ -163,7 +160,6 @@ export function createReducer<
163
160
  return { items }
164
161
  }
165
162
 
166
- // Internal event handling
167
163
  switch (event.type) {
168
164
  case 'files.added': {
169
165
  const ev = event
@@ -390,7 +386,6 @@ export function createReducer<
390
386
  const item = items.get(ev.localId)
391
387
  if (!item || item.phase !== 'completing') break
392
388
 
393
- // Increment attempt number for retry tracking
394
389
  const nextAttempt = (item.attempt ?? 1) + 1
395
390
 
396
391
  set(ev.localId, {
@@ -409,7 +404,6 @@ export function createReducer<
409
404
  }
410
405
  }
411
406
 
412
- // Helpers
413
407
  function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
414
408
  item: UploadItem<M, C, P, R>,
415
409
  canceledAt: number,
@@ -5,19 +5,8 @@ import { calculateFileChecksum, computeFingerprint } from '../store.libs'
5
5
  import type { StoreRuntime } from '../store.types'
6
6
 
7
7
  /**
8
- * Handles the `addFiles` command.
9
- *
10
- * This function performs fast synchronous work immediately (validation + state insertion)
11
- * and then schedules additional async work through the runtime effect queue
12
- * (checksum computation, de-dupe checks, and final validation transitions).
13
- *
14
- * @template M - Intent map type
15
- * @template C - Cursor map type
16
- * @template P - Purpose string union type
17
- *
18
- * @param rt - Store runtime
19
- * @param files - Files selected by the user
20
- * @param purpose - Upload purpose used for validation/routing
8
+ * Sync: validate + insert. Async (queued): checksum, dedupe lookup,
9
+ * and the final validation transition.
21
10
  */
22
11
  export function handleAddFiles<
23
12
  M extends IntentMap,
@@ -45,26 +34,23 @@ export function handleAddFiles<
45
34
  toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
46
35
 
47
36
  rt.enqueueEffect(async () => {
48
- // Calculate checksum for deduplication
49
37
  let checksum: string | undefined
50
38
  try {
51
39
  checksum = await calculateFileChecksum(file)
52
- // Update fingerprint with checksum
53
40
  const updatedFingerprint = { ...fingerprint, checksum }
54
41
  rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
55
42
  } catch (err) {
56
- // If checksum calculation fails, continue without checksum
43
+ // Checksum failure is non-fatal — continue without dedupe.
57
44
  if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
58
45
  console.warn('[UploadEngine] Failed to calculate checksum:', err)
59
46
  }
60
47
  }
61
48
 
62
- // Check for existing file by checksum if available
49
+ // Dedupe: skip upload entirely when the backend already has the file.
63
50
  if (checksum && rt.opts.api.findByChecksum) {
64
51
  try {
65
52
  const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose })
66
53
  if (existingFile) {
67
- // File already exists, skip upload and mark as completed
68
54
  const currentItem = rt.state.items.get(localId)
69
55
  if (currentItem && currentItem.phase === 'validating') {
70
56
  rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
@@ -72,21 +58,20 @@ export function handleAddFiles<
72
58
  }
73
59
  }
74
60
  } catch (err) {
75
- // If findByChecksum fails, continue with normal upload flow
61
+ // Dedupe failure is non-fatal — fall through to normal upload.
76
62
  if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
77
63
  console.warn('[UploadEngine] Failed to check for existing file:', err)
78
64
  }
79
65
  }
80
66
  }
81
67
 
82
- // Allow extra sync validation hook
83
68
  const reason = rt.opts.validateFile?.(file, purpose)
84
69
  if (reason) {
85
70
  rt.applyInternal({ type: 'validation.failed', localId, reason })
86
71
  return
87
72
  }
88
73
 
89
- // Still run config validation per-file (covers case where caller skipped validateFileList)
74
+ // Re-run config validation per-file in case the caller skipped validateFileList.
90
75
  const cfgReason = validateFile(file, purpose, rt.opts.config)
91
76
  if (cfgReason) {
92
77
  rt.applyInternal({ type: 'validation.failed', localId, reason: cfgReason })
@@ -10,7 +10,7 @@ export async function runUpload<
10
10
  >(rt: StoreRuntime<M, C, P, R>, localId: string) {
11
11
  const item = rt.state.items.get(localId)
12
12
 
13
- // If the state moved on (or we never had a valid upload), drop any inflight entry.
13
+ // State moved on (or never valid) — drop any stale inflight entry.
14
14
  if (!item || item.phase !== 'uploading' || !hasIntent(item)) {
15
15
  rt.inflightUploads.delete(localId)
16
16
  return
@@ -39,7 +39,7 @@ export async function runUpload<
39
39
  rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
40
40
  }
41
41
 
42
- // If paused/canceled before the request begins, complete it immediately.
42
+ // Paused/canceled before the request begins: short-circuit to the right terminal state.
43
43
  if (controller.signal.aborted) {
44
44
  rt.inflightUploads.delete(localId)
45
45
 
@@ -92,7 +92,7 @@ export async function runUpload<
92
92
  return
93
93
  }
94
94
 
95
- // Throttle progress events
95
+ // Throttle progress events to limit re-renders and reducer churn.
96
96
  let lastEmit = 0
97
97
  const progressThrottle = rt.opts.config.progressThrottleMs
98
98
 
@@ -138,16 +138,16 @@ export async function runUpload<
138
138
  return
139
139
  }
140
140
  if (controller.signal.aborted && inflight?.mode === 'pause') {
141
- // Paused at the end; reducer expects cursor - if not present, keep last-known or synthesize.
141
+ // Paused after strategy returned: reducer requires a cursor — synthesize an
142
+ // empty one if the strategy never persisted progress.
142
143
  const cur = rt.state.items.get(localId)
143
144
  const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
144
145
  applyPaused(cursor)
145
146
  return
146
147
  }
147
148
 
148
- // Normal completion -> completing
149
+ // 'completing' phase; finalizeUpload runs via scheduleWork().
149
150
  rt.applyInternal({ type: 'upload.ok', localId })
150
- // finalizeUpload scheduled by scheduleWork() via completing phase
151
151
  } catch (err: unknown) {
152
152
  rt.inflightUploads.delete(localId)
153
153
 
@@ -164,7 +164,7 @@ export async function runUpload<
164
164
  return
165
165
  }
166
166
 
167
- // Unknown abort -> treat as cancel
167
+ // Abort with no mode set: treat as cancel.
168
168
  applyCanceled()
169
169
  return
170
170
  }