@gentleduck/upload 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/README.md +170 -193
  2. package/dist/core/index.d.ts +2 -3
  3. package/dist/core/index.js +1 -1
  4. package/dist/core-BVgloTiP.js +10 -0
  5. package/dist/index-DCqyBVcx.d.ts +1441 -0
  6. package/dist/index-DsZHPmJm.d.ts +225 -0
  7. package/dist/index-KROXtLQh.d.ts +281 -0
  8. package/dist/index.d.ts +4 -5
  9. package/dist/index.js +1 -1
  10. package/dist/react/index.d.ts +2 -51
  11. package/dist/react/index.js +1 -1
  12. package/dist/react-euPrDv24.js +1 -0
  13. package/dist/strategies/index.d.ts +2 -56
  14. package/dist/strategies/index.js +1 -1
  15. package/dist/strategies-CUF0s68j.js +1 -0
  16. package/package.json +3 -2
  17. package/src/{__test__ → __tests__}/emitter.test.ts +4 -4
  18. package/src/{__test__ → __tests__}/fingerprint.test.ts +1 -1
  19. package/src/{__test__ → __tests__}/guards.test.ts +15 -5
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/{__test__ → __tests__}/persistence.test.ts +43 -8
  23. package/src/__tests__/sanitize-filename.test.ts +109 -0
  24. package/src/__tests__/sleep.test.ts +34 -0
  25. package/src/__tests__/static-analysis.test.ts +145 -0
  26. package/src/{__test__ → __tests__}/store-libs.test.ts +57 -3
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/{__test__ → __tests__}/transport.test.ts +5 -5
  29. package/src/core/__tests__/errors.test.ts +152 -0
  30. package/src/core/client.ts +3 -0
  31. package/src/core/contracts/contracts.types.ts +335 -141
  32. package/src/core/contracts/index.ts +3 -8
  33. package/src/core/contracts/transport/index.ts +3 -2
  34. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  35. package/src/core/contracts/transport/transport.libs.ts +7 -8
  36. package/src/core/contracts/transport/transport.ts +91 -83
  37. package/src/core/contracts/transport/transport.types.ts +71 -35
  38. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  39. package/src/core/engine/engine.libs.ts +35 -0
  40. package/src/core/engine/engine.types.ts +367 -241
  41. package/src/core/engine/index.ts +3 -8
  42. package/src/core/engine/reducer.ts +89 -43
  43. package/src/core/engine/store/handlers/add-file.ts +39 -29
  44. package/src/core/engine/store/handlers/cancel.ts +13 -8
  45. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  46. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  47. package/src/core/engine/store/handlers/finalize.ts +30 -13
  48. package/src/core/engine/store/handlers/index.ts +7 -7
  49. package/src/core/engine/store/handlers/pause.ts +8 -6
  50. package/src/core/engine/store/handlers/run-upload.ts +49 -34
  51. package/src/core/engine/store/index.ts +27 -21
  52. package/src/core/engine/store/store.dispatch.ts +10 -8
  53. package/src/core/engine/store/store.libs.ts +184 -41
  54. package/src/core/engine/store/store.runtime.ts +45 -36
  55. package/src/core/engine/store/store.schedule.ts +27 -21
  56. package/src/core/engine/store/store.types.ts +177 -227
  57. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  58. package/src/core/engine/validation/file.ts +7 -3
  59. package/src/core/engine/validation/index.ts +1 -0
  60. package/src/core/engine/validation/intent.ts +49 -27
  61. package/src/core/engine/validation/list.ts +14 -15
  62. package/src/core/engine/validation/mime.ts +39 -0
  63. package/src/core/errors.ts +271 -0
  64. package/src/core/index.ts +28 -5
  65. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  66. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  67. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  68. package/src/core/persistence/adapters.local.ts +9 -3
  69. package/src/core/persistence/adapters.memory.ts +35 -13
  70. package/src/core/persistence/index.ts +5 -5
  71. package/src/core/persistence/persistence.ts +73 -60
  72. package/src/core/persistence/persistence.types.ts +140 -60
  73. package/src/core/utils/__tests__/guards.test.ts +93 -0
  74. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  75. package/src/core/utils/async.ts +7 -2
  76. package/src/core/utils/constants.ts +3 -9
  77. package/src/core/utils/emitter/emitter.ts +7 -24
  78. package/src/core/utils/emitter/emitter.types.ts +33 -16
  79. package/src/core/utils/emitter/index.ts +2 -74
  80. package/src/core/utils/fingerprint.ts +16 -5
  81. package/src/core/utils/guards.ts +91 -1
  82. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  83. package/src/core/utils/hash/index.ts +126 -0
  84. package/src/core/utils/hash/sha256.ts +179 -0
  85. package/src/core/utils/id.ts +33 -1
  86. package/src/core/utils/index.ts +15 -6
  87. package/src/core/utils/mime-sniff.ts +119 -0
  88. package/src/core/utils/sanitize-filename.ts +85 -0
  89. package/src/core/utils/url-safety.ts +156 -0
  90. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  91. package/src/react/index.ts +5 -2
  92. package/src/react/upload-provider.tsx +68 -53
  93. package/src/react/uploader.types.ts +58 -61
  94. package/src/react/use-dropzone.tsx +226 -0
  95. package/src/react/use-uploader.tsx +98 -91
  96. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  97. package/src/strategies/_shared/retry.ts +79 -0
  98. package/src/strategies/index.ts +9 -15
  99. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  100. package/src/strategies/multipart/index.ts +206 -108
  101. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  102. package/src/strategies/post/index.ts +115 -33
  103. package/src/strategies/put/__tests__/put.test.ts +132 -0
  104. package/src/strategies/put/index.ts +139 -0
  105. package/src/strategies/registry/index.ts +12 -9
  106. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  107. package/src/strategies/tus/index.ts +211 -0
  108. package/dist/async-DxRWu4le.js +0 -1
  109. package/dist/core-CMkrP9DX.js +0 -2
  110. package/dist/index-C38Jmizu.d.ts +0 -1005
  111. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  112. package/src/__test__/effect-pool.test.ts +0 -5
  113. package/src/__test__/id.test.ts +0 -14
  114. package/src/__test__/store-integration.test.ts +0 -5
  115. package/src/core/client/client.ts +0 -50
  116. package/src/core/client/client.types.ts +0 -184
  117. package/src/core/client/config.types.ts +0 -77
  118. package/src/core/client/index.ts +0 -4
  119. package/src/core/client/plugins.types.ts +0 -46
  120. package/src/core/client/retry.types.ts +0 -6
  121. package/src/core/client/uploadClient.ts +0 -19
  122. package/src/core/contracts/backendApi.ts +0 -62
  123. package/src/core/contracts/cursor.types.ts +0 -23
  124. package/src/core/contracts/errors.types.ts +0 -67
  125. package/src/core/contracts/fingerprint.types.ts +0 -18
  126. package/src/core/contracts/intent.types.ts +0 -39
  127. package/src/core/contracts/result.types.ts +0 -11
  128. package/src/core/contracts/strategy.types.ts +0 -45
  129. package/src/core/engine/commands.types.ts +0 -17
  130. package/src/core/engine/event-map.types.ts +0 -45
  131. package/src/core/engine/internal-events.types.ts +0 -202
  132. package/src/core/engine/outcome.types.ts +0 -9
  133. package/src/core/engine/progress.types.ts +0 -15
  134. package/src/core/engine/store/handlers.ts +0 -819
  135. package/src/core/engine/validation.ts +0 -216
  136. /package/src/{__test__ → __tests__}/transport-libs.test.ts +0 -0
@@ -1,53 +1,102 @@
1
- import type { AnyIntent, CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
1
+ import type { Contracts } from '../../../contracts'
2
+ import type { UploadError } from '../../../errors'
3
+ import { UploadValidationError } from '../../../errors'
4
+ import { isRecord } from '../../../utils/guards'
5
+ import { sanitizeFilename } from '../../../utils/sanitize-filename'
2
6
  import { validateIntent } from '../../validation'
3
- import { normalizeError, retryDecision, sleep } from '../store.libs'
4
- import type { StoreRuntime } from '../store.types'
7
+ import {
8
+ buildApiContext,
9
+ normalizeError,
10
+ parseCreateIntentDedupeResult,
11
+ retryDecision,
12
+ sleep,
13
+ tryDedupeByChecksum,
14
+ } from '../store.libs'
15
+ import type { Store } from '../store.types'
5
16
 
6
17
  export async function createIntent<
7
- M extends IntentMap,
8
- C extends CursorMap<M>,
18
+ M extends Contracts.Intent.Map,
19
+ C extends Contracts.Cursor.Map<M>,
9
20
  P extends string,
10
- R extends UploadResultBase,
11
- >(rt: StoreRuntime<M, C, P, R>, localId: string) {
21
+ R extends Contracts.Result.Base,
22
+ >(rt: Store.Runtime<M, C, P, R>, localId: string) {
12
23
  const item = rt.state.items.get(localId)
13
- if (!item || item.phase !== 'creating_intent') return
24
+ if (item?.phase !== 'creating_intent') return
14
25
  if (rt.inflightIntents.has(localId)) return
15
26
 
27
+ const sanitised = sanitizeFilename(item.file.name)
28
+ if (!sanitised.safe) {
29
+ const error = new UploadValidationError(
30
+ { code: 'filename_rejected', reason: sanitised.reason },
31
+ 'filename rejected',
32
+ { context: { original: item.file.name, reason: sanitised.reason } },
33
+ )
34
+ rt.applyInternal({
35
+ type: 'intent.failed',
36
+ localId,
37
+ error,
38
+ retryable: false,
39
+ })
40
+ return
41
+ }
42
+
16
43
  const controller = new AbortController()
17
44
  rt.inflightIntents.set(localId, controller)
18
45
 
19
46
  try {
47
+ if (await tryDedupeByChecksum(rt, localId, item.fingerprint.checksum, item.purpose, ['creating_intent'])) {
48
+ return
49
+ }
50
+
51
+ const intentArgs: {
52
+ purpose: P
53
+ contentType: string
54
+ size: number
55
+ filename: string
56
+ checksum?: string
57
+ attempt: number
58
+ } = {
59
+ purpose: item.purpose,
60
+ contentType: item.file.type || 'application/octet-stream',
61
+ size: item.file.size,
62
+ filename: sanitised.normalised,
63
+ attempt: item.attempt ?? 1,
64
+ }
65
+ if (item.fingerprint.checksum !== undefined) {
66
+ intentArgs.checksum = item.fingerprint.checksum
67
+ }
68
+
20
69
  const intent = await rt.opts.api.createIntent(
21
- {
22
- purpose: item.purpose,
23
- contentType: item.file.type || 'application/octet-stream',
24
- size: item.file.size,
25
- filename: item.file.name,
26
- checksum: item.fingerprint.checksum,
27
- },
28
- { signal: controller.signal },
70
+ intentArgs,
71
+ buildApiContext(item, { signal: controller.signal }) as Contracts.Api.CreateIntentContext<P, M>,
29
72
  )
30
73
 
31
74
  rt.inflightIntents.delete(localId)
32
75
 
33
76
  // Item might have been canceled while intent was creating
34
77
  const current = rt.state.items.get(localId)
35
- if (!current || current.phase !== 'creating_intent') return
78
+ if (current?.phase !== 'creating_intent') return
79
+
80
+ const dedupeResult = parseCreateIntentDedupeResult<R>(intent)
81
+ if (dedupeResult) {
82
+ rt.applyInternal({ type: 'dedupe.ok', localId, result: dedupeResult })
83
+ return
84
+ }
36
85
 
37
86
  // Validate intent from backend
38
- const intentError = validateIntent(intent, intent.strategy)
87
+ const strategy = isRecord(intent) && typeof intent.strategy === 'string' ? intent.strategy : ''
88
+ const intentError = validateIntent(intent, strategy)
39
89
  if (intentError) {
40
- const error: UploadError = {
41
- code: 'validation_failed',
42
- message: `Invalid intent from backend: ${intentError.message}`,
43
- cause: intentError,
90
+ rt.applyInternal({
91
+ type: 'intent.failed',
92
+ localId,
93
+ error: intentError,
44
94
  retryable: false,
45
- }
46
- rt.applyInternal({ type: 'intent.failed', localId, error, retryable: false })
95
+ })
47
96
  return
48
97
  }
49
98
 
50
- rt.applyInternal({ type: 'intent.ok', localId, intent: intent as AnyIntent<M> })
99
+ rt.applyInternal({ type: 'intent.ok', localId, intent: intent as Contracts.Intent.Any<M> })
51
100
  } catch (err: unknown) {
52
101
  rt.inflightIntents.delete(localId)
53
102
 
@@ -59,10 +108,16 @@ export async function createIntent<
59
108
  const error = normalizeError(err, rt.opts.errorNormalizer)
60
109
  const errorWithContext: UploadError = {
61
110
  ...error,
62
- message: `${error.message} (file: ${item.fingerprint.name}, size: ${item.file.size} bytes, purpose: ${item.purpose})`,
111
+ context: {
112
+ ...((error as { context?: Record<string, unknown> }).context ?? {}),
113
+ filename: item.fingerprint.name,
114
+ size: item.file.size,
115
+ purpose: item.purpose,
116
+ },
63
117
  }
64
118
 
65
- const decision = retryDecision(rt.opts.config, { phase: 'intent', attempt: item.attempt, error: errorWithContext })
119
+ const attempt = item.attempt ?? 1
120
+ const decision = retryDecision(rt.opts.config, { phase: 'intent', attempt, error: errorWithContext })
66
121
 
67
122
  rt.applyInternal({ type: 'intent.failed', localId, error: errorWithContext, retryable: decision.retryable })
68
123
 
@@ -1,15 +1,20 @@
1
- import type { CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
2
- import { hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
3
- import type { StoreRuntime } from '../store.types'
1
+ import type { Contracts } from '../../../contracts'
2
+ import type { UploadError } from '../../../errors'
3
+ import { buildApiContext, hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
4
+ import type { Store } from '../store.types'
5
+
6
+ function hasContext(e: UploadError): e is UploadError & { context: Record<string, unknown> } {
7
+ return typeof e === 'object' && e !== null && 'context' in e && typeof e.context === 'object' && e.context !== null
8
+ }
4
9
 
5
10
  export async function finalizeUpload<
6
- M extends IntentMap,
7
- C extends CursorMap<M>,
11
+ M extends Contracts.Intent.Map,
12
+ C extends Contracts.Cursor.Map<M>,
8
13
  P extends string,
9
- R extends UploadResultBase,
10
- >(rt: StoreRuntime<M, C, P, R>, localId: string) {
14
+ R extends Contracts.Result.Base,
15
+ >(rt: Store.Runtime<M, C, P, R>, localId: string) {
11
16
  const item = rt.state.items.get(localId)
12
- if (!item || item.phase !== 'completing') return
17
+ if (item?.phase !== 'completing') return
13
18
  if (rt.inflightCompletes.has(localId)) return
14
19
 
15
20
  const controller = new AbortController()
@@ -18,12 +23,22 @@ export async function finalizeUpload<
18
23
  try {
19
24
  // Final commit step (backend should verify upload, then mark/commit it).
20
25
  // Strategy-specific API calls (multipart/tus) must happen inside the strategy itself.
21
- const result = await rt.opts.api.complete({ fileId: item.intent.fileId }, { signal: controller.signal })
26
+ const result = await rt.opts.api.complete(
27
+ {
28
+ fileId: item.intent.fileId,
29
+ filename: item.fingerprint.name,
30
+ contentType: item.file.type || 'application/octet-stream',
31
+ size: item.fingerprint.size,
32
+ ...(item.fingerprint.checksum !== undefined ? { checksum: item.fingerprint.checksum } : {}),
33
+ attempt: item.attempt ?? 1,
34
+ },
35
+ buildApiContext(item, { signal: controller.signal }) as Contracts.Api.CompleteContext<P, M>,
36
+ )
22
37
  rt.inflightCompletes.delete(localId)
23
38
 
24
39
  // Item might have been canceled
25
40
  const current = rt.state.items.get(localId)
26
- if (!current || current.phase !== 'completing') return
41
+ if (current?.phase !== 'completing') return
27
42
 
28
43
  rt.applyInternal({ type: 'complete.ok', localId, result })
29
44
  } catch (err: unknown) {
@@ -37,9 +52,11 @@ export async function finalizeUpload<
37
52
  const errorWithContext: UploadError = itemForContext
38
53
  ? {
39
54
  ...error,
40
- message: `${error.message} (file: ${itemForContext.fingerprint.name}, fileId: ${
41
- (hasIntent(itemForContext) && itemForContext.intent?.fileId) ?? 'unknown'
42
- })`,
55
+ context: {
56
+ ...(hasContext(error) ? error.context : {}),
57
+ filename: itemForContext.fingerprint.name,
58
+ fileId: (hasIntent(itemForContext) && itemForContext.intent?.fileId) ?? 'unknown',
59
+ },
43
60
  }
44
61
  : error
45
62
 
@@ -1,7 +1,7 @@
1
- export * from './add-file'
2
- export * from './cancel'
3
- export * from './clean-up'
4
- export * from './create-intent'
5
- export * from './finalize'
6
- export * from './pause'
7
- export * from './run-upload'
1
+ export { handleAddFiles } from './add-file'
2
+ export { handleCancel } from './cancel'
3
+ export { cleanupOldItems } from './clean-up'
4
+ export { createIntent } from './create-intent'
5
+ export { finalizeUpload } from './finalize'
6
+ export { handlePause } from './pause'
7
+ export { runUpload } from './run-upload'
@@ -1,10 +1,12 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
2
- import type { StoreRuntime } from '../store.types'
1
+ import type { Contracts } from '../../../contracts'
2
+ import type { Store } from '../store.types'
3
3
 
4
- export function handlePause<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
5
- rt: StoreRuntime<M, C, P, R>,
6
- localId: string,
7
- ) {
4
+ export function handlePause<
5
+ M extends Contracts.Intent.Map,
6
+ C extends Contracts.Cursor.Map<M>,
7
+ P extends string,
8
+ R extends Contracts.Result.Base,
9
+ >(rt: Store.Runtime<M, C, P, R>, localId: string) {
8
10
  const inflight = rt.inflightUploads.get(localId)
9
11
  if (inflight) {
10
12
  inflight.mode = 'pause'
@@ -1,17 +1,19 @@
1
- import type { AnyCursor, CursorMap, IntentMap, UploadError, UploadResultBase } from '../../../contracts'
1
+ import type { Contracts } from '../../../contracts'
2
+ import type { UploadError } from '../../../errors'
3
+ import { UploadStrategyMissingError } from '../../../errors'
2
4
  import { hasCursor, hasFile, hasIntent, normalizeError, retryDecision, sleep } from '../store.libs'
3
- import type { StoreRuntime } from '../store.types'
5
+ import type { Store } from '../store.types'
4
6
 
5
7
  export async function runUpload<
6
- M extends IntentMap,
7
- C extends CursorMap<M>,
8
+ M extends Contracts.Intent.Map,
9
+ C extends Contracts.Cursor.Map<M>,
8
10
  P extends string,
9
- R extends UploadResultBase,
10
- >(rt: StoreRuntime<M, C, P, R>, localId: string) {
11
+ R extends Contracts.Result.Base,
12
+ >(rt: Store.Runtime<M, C, P, R>, localId: string) {
11
13
  const item = rt.state.items.get(localId)
12
14
 
13
- // State moved on (or never valid) — drop any stale inflight entry.
14
- if (!item || item.phase !== 'uploading' || !hasIntent(item)) {
15
+ // State moved on (or never valid) drop any stale inflight entry.
16
+ if (item?.phase !== 'uploading' || !hasIntent(item)) {
15
17
  rt.inflightUploads.delete(localId)
16
18
  return
17
19
  }
@@ -33,25 +35,24 @@ export async function runUpload<
33
35
  rt.applyInternal({ type: 'canceled', localId, canceledAt: Date.now() })
34
36
  }
35
37
 
36
- const applyPaused = (cursor: AnyCursor<C>) => {
38
+ const applyPaused = (cursor: Contracts.Cursor.Any<C>) => {
37
39
  const current = rt.state.items.get(localId)
38
40
  if (!current || current.phase === 'paused') return
39
41
  rt.applyInternal({ type: 'paused', localId, cursor, pausedAt: Date.now() })
40
42
  }
41
43
 
44
+ const strategyId = item.intent.strategy
45
+
42
46
  // Paused/canceled before the request begins: short-circuit to the right terminal state.
43
47
  if (controller.signal.aborted) {
44
48
  rt.inflightUploads.delete(localId)
45
49
 
46
50
  if (inflight.mode === 'pause') {
47
51
  const cur = rt.state.items.get(localId)
48
- let strategy: (keyof M & string) | undefined
49
- if (item && hasIntent(item)) {
50
- strategy = item.intent.strategy
51
- }
52
52
 
53
53
  const cursor =
54
- (cur && hasCursor(cur) ? cur.cursor : undefined) || (strategy ? { strategy, value: undefined } : undefined)
54
+ (cur && hasCursor(cur) ? cur.cursor : undefined) ||
55
+ ({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
55
56
 
56
57
  if (cursor) applyPaused(cursor)
57
58
  return
@@ -65,16 +66,15 @@ export async function runUpload<
65
66
  return
66
67
  }
67
68
 
68
- const strategyId = item.intent.strategy
69
69
  if (!rt.opts.strategies.has(strategyId)) {
70
70
  rt.inflightUploads.delete(localId)
71
- const error: UploadError = {
72
- code: 'strategy_missing',
73
- message: `Strategy not found: ${strategyId}`,
74
- strategy: String(strategyId),
71
+ const error = new UploadStrategyMissingError(String(strategyId))
72
+ rt.applyInternal({
73
+ type: 'upload.failed',
74
+ localId,
75
+ error,
75
76
  retryable: false,
76
- }
77
- rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
77
+ })
78
78
  return
79
79
  }
80
80
 
@@ -82,13 +82,13 @@ export async function runUpload<
82
82
 
83
83
  if (!strategy) {
84
84
  rt.inflightUploads.delete(localId)
85
- const error: UploadError = {
86
- code: 'strategy_missing',
87
- message: `Strategy not found: ${item.intent.strategy}`,
88
- strategy: String(item.intent.strategy),
85
+ const error = new UploadStrategyMissingError(String(item.intent.strategy))
86
+ rt.applyInternal({
87
+ type: 'upload.failed',
88
+ localId,
89
+ error,
89
90
  retryable: false,
90
- }
91
- rt.applyInternal({ type: 'upload.failed', localId, error, retryable: false })
91
+ })
92
92
  return
93
93
  }
94
94
 
@@ -98,11 +98,19 @@ export async function runUpload<
98
98
 
99
99
  try {
100
100
  await strategy.start({
101
+ localId,
102
+ purpose: item.purpose,
101
103
  file: item.file,
102
104
  intent: item.intent,
103
105
  signal: controller.signal,
104
106
  transport: rt.opts.transport,
105
107
  api: rt.opts.api,
108
+ fingerprint: item.fingerprint,
109
+ attempt: item.attempt ?? 1,
110
+ createdAt: item.createdAt,
111
+ steps: item.steps,
112
+ meta: item.meta,
113
+ lastError: [...item.steps].reverse().find((s) => s.error)?.error,
106
114
 
107
115
  readCursor: () => {
108
116
  const cur = rt.state.items.get(localId)
@@ -112,7 +120,7 @@ export async function runUpload<
112
120
  },
113
121
 
114
122
  persistCursor: (cursorValue) => {
115
- const tagged: AnyCursor<C> = { strategy: strategyId, value: cursorValue }
123
+ const tagged = { strategy: strategyId, value: cursorValue } as Contracts.Cursor.Any<C>
116
124
  rt.applyInternal({ type: 'cursor.updated', localId, cursor: tagged })
117
125
  },
118
126
 
@@ -138,10 +146,12 @@ export async function runUpload<
138
146
  return
139
147
  }
140
148
  if (controller.signal.aborted && inflight?.mode === 'pause') {
141
- // Paused after strategy returned: reducer requires a cursor — synthesize an
149
+ // Paused after strategy returned: reducer requires a cursor synthesize an
142
150
  // empty one if the strategy never persisted progress.
143
151
  const cur = rt.state.items.get(localId)
144
- const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
152
+ const cursor =
153
+ (cur && hasCursor(cur) ? cur.cursor : undefined) ||
154
+ ({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
145
155
  applyPaused(cursor)
146
156
  return
147
157
  }
@@ -154,7 +164,9 @@ export async function runUpload<
154
164
  if (controller.signal.aborted) {
155
165
  if (inflight?.mode === 'pause') {
156
166
  const cur = rt.state.items.get(localId)
157
- const cursor = (cur && hasCursor(cur) ? cur.cursor : undefined) || { strategy: strategyId, value: undefined }
167
+ const cursor =
168
+ (cur && hasCursor(cur) ? cur.cursor : undefined) ||
169
+ ({ strategy: strategyId, value: undefined } as Contracts.Cursor.Any<C>)
158
170
  applyPaused(cursor)
159
171
  return
160
172
  }
@@ -174,9 +186,12 @@ export async function runUpload<
174
186
  const errorWithContext: UploadError = itemForContext
175
187
  ? {
176
188
  ...error,
177
- message: `${error.message} (file: ${itemForContext.fingerprint.name}, size: ${
178
- (hasFile(itemForContext) && itemForContext.file?.size) ?? 0
179
- } bytes, phase: ${itemForContext.phase})`,
189
+ context: {
190
+ ...((error as { context?: Record<string, unknown> }).context ?? {}),
191
+ filename: itemForContext.fingerprint.name,
192
+ size: (hasFile(itemForContext) && itemForContext.file?.size) ?? 0,
193
+ phase: itemForContext.phase,
194
+ },
180
195
  }
181
196
  : error
182
197
  const decision = retryDecision(rt.opts.config, { phase: 'upload', attempt: 1, error: errorWithContext })
@@ -1,8 +1,9 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
2
- import type { UploadOutcome } from '../outcome.types'
1
+ import type { Contracts } from '../../contracts'
2
+ import { isDevEnv } from '../../utils/guards'
3
+ import type { Engine } from '../engine.types'
3
4
  import { dispatch as internalDispatch } from './store.dispatch'
4
5
  import { createStoreRuntime } from './store.runtime'
5
- import type { StoreOptions, StoreRuntime, UploadStore } from './store.types'
6
+ import type { Store } from './store.types'
6
7
 
7
8
  // TODO: add option for debug mode
8
9
 
@@ -22,20 +23,21 @@ import type { StoreOptions, StoreRuntime, UploadStore } from './store.types'
22
23
  * @template P - Purpose string union type
23
24
  *
24
25
  * @param opts - Store options (config, backend API, strategies, transport, hooks)
25
- * @returns A configured {@link UploadStore} instance.
26
+ * @returns A configured {@link Store.UploadStore} instance.
27
+ * @author wildduck2 <https://github.com/wildduck2>
26
28
  */
27
29
  export function createUploadStore<
28
- M extends IntentMap,
29
- C extends CursorMap<M>,
30
+ M extends Contracts.Intent.Map,
31
+ C extends Contracts.Cursor.Map<M>,
30
32
  P extends string,
31
- R extends UploadResultBase = UploadResultBase,
32
- >(opts: StoreOptions<M, C, P, R>): UploadStore<M, C, P, R> {
33
+ R extends Contracts.Result.Base = Contracts.Result.Base,
34
+ >(opts: Store.Options<M, C, P, R>): Store.UploadStore<M, C, P, R> {
33
35
  const rt = createStoreRuntime(opts)
34
36
 
35
37
  // Wire dispatch for handlers that need retries without import cycles.
36
38
  rt.dispatch = (cmd) => internalDispatch(rt, cmd)
37
39
 
38
- const storeProxy: Pick<UploadStore<M, C, P, R>, 'on' | 'off' | 'dispatch' | 'getSnapshot'> = {
40
+ const storeProxy: Pick<Store.UploadStore<M, C, P, R>, 'on' | 'off' | 'dispatch' | 'getSnapshot'> = {
39
41
  on: rt.emitter.on.bind(rt.emitter),
40
42
  off: rt.emitter.off.bind(rt.emitter),
41
43
  dispatch: rt.dispatch,
@@ -47,7 +49,7 @@ export function createUploadStore<
47
49
  try {
48
50
  plugin.setup(storeProxy)
49
51
  } catch (err) {
50
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
52
+ if (isDevEnv()) {
51
53
  console.error(`[UploadEngine] Plugin "${plugin.name}" failed to setup:`, err)
52
54
  }
53
55
  }
@@ -55,32 +57,34 @@ export function createUploadStore<
55
57
 
56
58
  return {
57
59
  ...storeProxy,
58
- subscribe: (listener) => {
60
+ subscribe: (listener: () => void) => {
59
61
  rt.listeners.add(listener)
60
62
  return () => rt.listeners.delete(listener)
61
63
  },
62
- waitFor(localIds) {
64
+ waitFor(localIds: string[]) {
63
65
  return waitForOutcomes(rt, localIds)
64
66
  },
65
67
  }
66
68
  }
67
69
 
68
- export * from './store.types'
70
+ export type { Store } from './store.types'
69
71
 
70
- function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
71
- rt: StoreRuntime<M, C, P, R>,
72
- localIds: string[],
73
- ): Promise<Array<UploadOutcome<R>>> {
72
+ function waitForOutcomes<
73
+ M extends Contracts.Intent.Map,
74
+ C extends Contracts.Cursor.Map<M>,
75
+ P extends string,
76
+ R extends Contracts.Result.Base,
77
+ >(rt: Store.Runtime<M, C, P, R>, localIds: string[]): Promise<Array<Engine.Outcome<R>>> {
74
78
  if (localIds.length === 0) return Promise.resolve([])
75
79
 
76
80
  const pending = new Set(localIds)
77
- const outcomes = new Map<string, UploadOutcome<R>>()
81
+ const outcomes = new Map<string, Engine.Outcome<R>>()
78
82
 
79
83
  const update = () => {
80
84
  for (const id of pending) {
81
85
  const item = rt.state.items.get(id)
82
86
  if (!item) {
83
- outcomes.set(id, { localId: id, status: 'missing' })
87
+ outcomes.set(id, { localId: id, status: 'missing', reason: 'never-existed' })
84
88
  pending.delete(id)
85
89
  continue
86
90
  }
@@ -107,7 +111,9 @@ function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends
107
111
  update()
108
112
 
109
113
  if (pending.size === 0) {
110
- return Promise.resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing' }))
114
+ return Promise.resolve(
115
+ localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing', reason: 'never-existed' }),
116
+ )
111
117
  }
112
118
 
113
119
  return new Promise((resolve) => {
@@ -115,7 +121,7 @@ function waitForOutcomes<M extends IntentMap, C extends CursorMap<M>, P extends
115
121
  update()
116
122
  if (pending.size === 0) {
117
123
  rt.listeners.delete(listener)
118
- resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing' }))
124
+ resolve(localIds.map((id) => outcomes.get(id) ?? { localId: id, status: 'missing', reason: 'never-existed' }))
119
125
  }
120
126
  }
121
127
  rt.listeners.add(listener)
@@ -1,9 +1,9 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
2
- import type { UploadCommand } from '../commands.types'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
  import { handleAddFiles } from './handlers/add-file'
4
4
  import { handleCancel } from './handlers/cancel'
5
5
  import { handlePause } from './handlers/pause'
6
- import type { StoreRuntime } from './store.types'
6
+ import type { Store } from './store.types'
7
7
 
8
8
  /**
9
9
  * Dispatches a command against the runtime.
@@ -16,10 +16,12 @@ import type { StoreRuntime } from './store.types'
16
16
  * @template C - Cursor map type
17
17
  * @template P - Purpose string union type
18
18
  */
19
- export function dispatch<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
20
- rt: StoreRuntime<M, C, P, R>,
21
- cmd: UploadCommand<P>,
22
- ) {
19
+ export function dispatch<
20
+ M extends Contracts.Intent.Map,
21
+ C extends Contracts.Cursor.Map<M>,
22
+ P extends string,
23
+ R extends Contracts.Result.Base,
24
+ >(rt: Store.Runtime<M, C, P, R>, cmd: Engine.Command<P>) {
23
25
  // Bulk operations
24
26
  if (cmd.type === 'startAll') {
25
27
  const purpose = cmd.purpose
@@ -54,7 +56,7 @@ export function dispatch<M extends IntentMap, C extends CursorMap<M>, P extends
54
56
  }
55
57
 
56
58
  if (cmd.type === 'addFiles') {
57
- handleAddFiles(rt, cmd.files, cmd.purpose)
59
+ handleAddFiles(rt, cmd.files, cmd.purpose, cmd.meta ?? {})
58
60
  return
59
61
  }
60
62