@gentleduck/upload 0.2.5 → 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 (129) hide show
  1. package/README.md +297 -0
  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 -90
  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/__tests__/emitter.test.ts +85 -0
  18. package/src/__tests__/fingerprint.test.ts +49 -0
  19. package/src/__tests__/guards.test.ts +40 -0
  20. package/src/__tests__/id.test.ts +67 -0
  21. package/src/__tests__/mime-sniff.test.ts +142 -0
  22. package/src/__tests__/persistence.test.ts +191 -0
  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/__tests__/store-libs.test.ts +192 -0
  27. package/src/__tests__/transport-fetch.test.ts +108 -0
  28. package/src/__tests__/transport-libs.test.ts +42 -0
  29. package/src/__tests__/transport.test.ts +182 -0
  30. package/src/core/__tests__/errors.test.ts +152 -0
  31. package/src/core/client.ts +3 -0
  32. package/src/core/contracts/contracts.types.ts +373 -0
  33. package/src/core/contracts/index.ts +3 -8
  34. package/src/core/contracts/transport/index.ts +3 -2
  35. package/src/core/contracts/transport/transport.fetch.ts +203 -0
  36. package/src/core/contracts/transport/transport.libs.ts +13 -39
  37. package/src/core/contracts/transport/transport.ts +96 -90
  38. package/src/core/contracts/transport/transport.types.ts +71 -35
  39. package/src/core/engine/__tests__/config-nan-bypass.test.ts +40 -0
  40. package/src/core/engine/engine.libs.ts +35 -0
  41. package/src/core/engine/engine.types.ts +490 -0
  42. package/src/core/engine/index.ts +3 -8
  43. package/src/core/engine/reducer.ts +93 -53
  44. package/src/core/engine/store/handlers/add-file.ts +43 -48
  45. package/src/core/engine/store/handlers/cancel.ts +13 -8
  46. package/src/core/engine/store/handlers/clean-up.ts +11 -8
  47. package/src/core/engine/store/handlers/create-intent.ts +82 -27
  48. package/src/core/engine/store/handlers/finalize.ts +30 -13
  49. package/src/core/engine/store/handlers/index.ts +7 -7
  50. package/src/core/engine/store/handlers/pause.ts +8 -6
  51. package/src/core/engine/store/handlers/run-upload.ts +54 -39
  52. package/src/core/engine/store/index.ts +27 -21
  53. package/src/core/engine/store/store.dispatch.ts +10 -8
  54. package/src/core/engine/store/store.libs.ts +197 -94
  55. package/src/core/engine/store/store.runtime.ts +52 -53
  56. package/src/core/engine/store/store.schedule.ts +27 -21
  57. package/src/core/engine/store/store.types.ts +177 -227
  58. package/src/core/engine/validation/__tests__/intent.test.ts +70 -0
  59. package/src/core/engine/validation/file.ts +8 -4
  60. package/src/core/engine/validation/index.ts +1 -0
  61. package/src/core/engine/validation/intent.ts +49 -27
  62. package/src/core/engine/validation/list.ts +14 -15
  63. package/src/core/engine/validation/mime.ts +39 -0
  64. package/src/core/errors.ts +271 -0
  65. package/src/core/index.ts +28 -5
  66. package/src/core/persistence/__tests__/deserialize-nan-bypass.test.ts +100 -0
  67. package/src/core/persistence/__tests__/persistence.test.ts +85 -0
  68. package/src/core/persistence/adapters.indexeddb.ts +9 -3
  69. package/src/core/persistence/adapters.local.ts +9 -3
  70. package/src/core/persistence/adapters.memory.ts +35 -13
  71. package/src/core/persistence/index.ts +5 -5
  72. package/src/core/persistence/persistence.ts +73 -60
  73. package/src/core/persistence/persistence.types.ts +140 -60
  74. package/src/core/utils/__tests__/guards.test.ts +93 -0
  75. package/src/core/utils/__tests__/url-safety.test.ts +112 -0
  76. package/src/core/utils/async.ts +7 -2
  77. package/src/core/utils/constants.ts +3 -9
  78. package/src/core/utils/emitter/emitter.ts +126 -0
  79. package/src/core/utils/emitter/emitter.types.ts +33 -16
  80. package/src/core/utils/emitter/index.ts +2 -74
  81. package/src/core/utils/fingerprint.ts +16 -5
  82. package/src/core/utils/guards.ts +91 -1
  83. package/src/core/utils/hash/__tests__/sha256.test.ts +61 -0
  84. package/src/core/utils/hash/index.ts +126 -0
  85. package/src/core/utils/hash/sha256.ts +179 -0
  86. package/src/core/utils/id.ts +33 -1
  87. package/src/core/utils/index.ts +15 -6
  88. package/src/core/utils/mime-sniff.ts +119 -0
  89. package/src/core/utils/sanitize-filename.ts +85 -0
  90. package/src/core/utils/url-safety.ts +156 -0
  91. package/src/react/__tests__/use-dropzone.test.ts +64 -0
  92. package/src/react/index.ts +5 -2
  93. package/src/react/upload-provider.tsx +59 -95
  94. package/src/react/uploader.types.ts +80 -0
  95. package/src/react/use-dropzone.tsx +226 -0
  96. package/src/react/use-uploader.tsx +97 -93
  97. package/src/strategies/_shared/__tests__/retry.test.ts +120 -0
  98. package/src/strategies/_shared/retry.ts +79 -0
  99. package/src/strategies/index.ts +9 -15
  100. package/src/strategies/multipart/__tests__/multipart.test.ts +229 -0
  101. package/src/strategies/multipart/index.ts +211 -116
  102. package/src/strategies/post/__tests__/post-url-safety.test.ts +119 -0
  103. package/src/strategies/post/index.ts +113 -71
  104. package/src/strategies/put/__tests__/put.test.ts +132 -0
  105. package/src/strategies/put/index.ts +139 -0
  106. package/src/strategies/registry/index.ts +12 -9
  107. package/src/strategies/tus/__tests__/tus.test.ts +159 -0
  108. package/src/strategies/tus/index.ts +211 -0
  109. package/dist/async-DxRWu4le.js +0 -1
  110. package/dist/core-BNxznRif.js +0 -2
  111. package/dist/index-BXg9WIce.d.ts +0 -987
  112. package/dist/strategy.types-gg9ZStRe.d.ts +0 -225
  113. package/src/core/client/config.types.ts +0 -77
  114. package/src/core/client/index.ts +0 -4
  115. package/src/core/client/plugins.types.ts +0 -46
  116. package/src/core/client/retry.types.ts +0 -6
  117. package/src/core/client/uploadClient.ts +0 -19
  118. package/src/core/contracts/backendApi.ts +0 -62
  119. package/src/core/contracts/cursor.types.ts +0 -23
  120. package/src/core/contracts/errors.types.ts +0 -67
  121. package/src/core/contracts/fingerprint.types.ts +0 -18
  122. package/src/core/contracts/intent.types.ts +0 -39
  123. package/src/core/contracts/result.types.ts +0 -11
  124. package/src/core/contracts/strategy.types.ts +0 -45
  125. package/src/core/engine/commands.types.ts +0 -17
  126. package/src/core/engine/event-map.types.ts +0 -45
  127. package/src/core/engine/internal-events.types.ts +0 -222
  128. package/src/core/engine/outcome.types.ts +0 -9
  129. package/src/core/engine/progress.types.ts +0 -15
@@ -1,159 +1,263 @@
1
- import type { UploadConfig } from '../../client'
2
- import type { AnyCursor, CursorMap, IntentMap, UploadError, UploadResultBase } from '../../contracts'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { UploadError } from '../../errors'
3
3
  import { DEFAULT_RETRY_DELAY_BASE_MS, DEFAULT_RETRY_DELAY_MAX_MS } from '../../utils/constants'
4
4
  import { computeFingerprint, fingerprintMatches } from '../../utils/fingerprint'
5
- import { isRecord } from '../../utils/guards'
6
- import type { UploadItem } from '../internal-events.types'
5
+ import { isDevEnv, isRecord } from '../../utils/guards'
6
+ import { hashBlob } from '../../utils/hash'
7
+ import type { Engine } from '../engine.types'
8
+ import type { Store } from './store.types'
7
9
 
8
10
  export { sleep } from '../../utils/async'
9
11
 
10
- import type { StoreOptions } from './store.types'
12
+ /** Build enriched API context from a live item for passing to any backend API method. */
13
+ export function buildApiContext<
14
+ M extends Contracts.Intent.Map,
15
+ C extends Contracts.Cursor.Map<M>,
16
+ P extends string,
17
+ R extends Contracts.Result.Base,
18
+ >(item: Engine.Item<M, C, P, R>, overrides: Partial<Contracts.Api.Context<P, M>> = {}): Contracts.Api.Context<P, M> {
19
+ const lastErrorStep = [...item.steps].reverse().find((s) => s.error !== undefined)
20
+ return {
21
+ localId: item.localId,
22
+ purpose: item.purpose,
23
+ fingerprint: item.fingerprint,
24
+ attempt: item.attempt ?? 1,
25
+ createdAt: item.createdAt,
26
+ steps: item.steps,
27
+ meta: item.meta,
28
+ lastError: lastErrorStep?.error,
29
+ file: 'file' in item ? item.file : undefined,
30
+ intent: 'intent' in item ? (item as { intent?: Contracts.Intent.Any<M> }).intent : undefined,
31
+ ...overrides,
32
+ }
33
+ }
11
34
 
12
35
  /**
13
- * Calculates SHA256 checksum of a file for deduplication.
14
- * Uses Web Crypto API for efficient hashing.
36
+ * Default boundary between inline and incremental hashing when
37
+ * `config.checksumMaxSize` is unset (`null`) or `0`: 64 MiB.
15
38
  */
16
- export async function calculateFileChecksum(file: File): Promise<string> {
17
- const buffer = await file.arrayBuffer()
18
- const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
19
- const hashArray = Array.from(new Uint8Array(hashBuffer))
20
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
21
- }
39
+ export const DEFAULT_CHECKSUM_MAX_SIZE = 64 * 1024 * 1024
22
40
 
23
41
  /**
24
- * Determines whether items for a given purpose should auto-start.
42
+ * SHA-256 checksum of `file` for deduplication.
43
+ *
44
+ * `maxSize` is the inline/incremental boundary, not a skip: files at or below
45
+ * it are hashed with native Web Crypto (fastest), while larger files are hashed
46
+ * incrementally in bounded slices — off the main thread via a Web Worker when
47
+ * available, otherwise inline-incremental. Memory stays flat either way, so
48
+ * dedupe now covers files of any size.
25
49
  *
26
- * Supports three configuration shapes:
27
- * - `false | undefined`: never auto-start
28
- * - `P[]`: auto-start for purposes included in the list
29
- * - `(purpose: P) => boolean`: custom predicate
50
+ * Pass `maxSize = 0` or omit it to use {@link DEFAULT_CHECKSUM_MAX_SIZE}.
51
+ *
52
+ * @returns lowercase hex SHA-256 digest.
30
53
  */
31
- export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
32
- opts: StoreOptions<M, C, P, R>,
33
- purpose: P,
34
- ): boolean {
54
+ export async function calculateFileChecksum(file: File, maxSize: number | null = null): Promise<string> {
55
+ // Use the caller-supplied boundary only when it's a finite positive number.
56
+ // NaN / Infinity / 0 / negative all fall back to the conservative default.
57
+ const inlineMaxSize =
58
+ typeof maxSize === 'number' && Number.isFinite(maxSize) && maxSize > 0 ? maxSize : DEFAULT_CHECKSUM_MAX_SIZE
59
+ return hashBlob(file, { inlineMaxSize })
60
+ }
61
+
62
+ /**
63
+ * Resolve auto-start config: `false | undefined` (never), `P[]` (in list), or
64
+ * `(purpose) => boolean` predicate.
65
+ */
66
+ export function isAutoStart<
67
+ M extends Contracts.Intent.Map,
68
+ C extends Contracts.Cursor.Map<M>,
69
+ P extends string,
70
+ R extends Contracts.Result.Base,
71
+ >(opts: Store.Options<M, C, P, R>, purpose: P): boolean {
35
72
  const v = opts.config?.autoStart
36
73
  if (v === undefined) return false
37
74
  if (Array.isArray(v)) return v.includes(purpose)
38
- if (typeof v === 'function') return v(purpose)
75
+ if (typeof v === 'function') {
76
+ // Fail-closed if the user-supplied predicate throws so a buggy
77
+ // predicate can't stop the entire addFiles flow.
78
+ try {
79
+ return v(purpose) === true
80
+ } catch {
81
+ return false
82
+ }
83
+ }
39
84
  return false
40
85
  }
41
86
 
42
87
  /**
43
- * Builds a lightweight fingerprint from a {@link File}.
44
- *
45
- * This fingerprint is used for display and basic identity. If you need stronger identity
46
- * (e.g. SHA-256), provide a custom {@link StoreOptions.fingerprint} function or let the
47
- * add-file handler compute a checksum asynchronously.
88
+ * Lightweight `File` fingerprint for display/identity. For stronger identity
89
+ * (SHA-256), pass {@link StoreOptions.fingerprint} or let add-file compute a
90
+ * checksum asynchronously.
48
91
  */
49
92
  export { computeFingerprint, fingerprintMatches }
50
93
 
51
94
  /**
52
- * Normalizes unknown thrown values into the engine's {@link UploadError} shape.
53
- *
54
- * You can provide a custom normalizer through {@link StoreOptions.errorNormalizer}.
55
- * Built-in behavior:
56
- * - detects abort errors and marks them non-retryable
57
- * - classifies common network and HTTP errors as retryable where appropriate
95
+ * When `findByChecksum` matches, complete the item without creating an intent or
96
+ * uploading bytes. Returns `true` when dedupe succeeded.
58
97
  */
59
- export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => UploadError): UploadError {
98
+ export async function tryDedupeByChecksum<
99
+ M extends Contracts.Intent.Map,
100
+ C extends Contracts.Cursor.Map<M>,
101
+ P extends string,
102
+ R extends Contracts.Result.Base,
103
+ >(
104
+ rt: Store.Runtime<M, C, P, R>,
105
+ localId: string,
106
+ checksum: string | undefined,
107
+ purpose: P,
108
+ allowedPhases: ReadonlyArray<Engine.Phase>,
109
+ ): Promise<boolean> {
110
+ if (!checksum || !rt.opts.api.findByChecksum) return false
111
+
112
+ const item = rt.state.items.get(localId)
113
+ if (!item || !allowedPhases.includes(item.phase) || !item.file) return false
114
+
115
+ try {
116
+ const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose }, {
117
+ ...buildApiContext(item),
118
+ file: item.file,
119
+ } as Contracts.Api.FindByChecksumContext<P, M>)
120
+ if (!existingFile) return false
121
+
122
+ const current = rt.state.items.get(localId)
123
+ if (!current || !allowedPhases.includes(current.phase)) return false
124
+
125
+ rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
126
+ return true
127
+ } catch (err) {
128
+ if (isDevEnv()) {
129
+ console.warn('[UploadEngine] Failed to check for existing file:', err)
130
+ }
131
+ return false
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Detects when `createIntent` returned an existing file result instead of an
137
+ * upload intent (e.g. backend matched checksum during plan-upload).
138
+ */
139
+ export function parseCreateIntentDedupeResult<R extends Contracts.Result.Base>(response: unknown): R | null {
140
+ if (!isRecord(response)) return null
141
+
142
+ const strategy = response['strategy']
143
+ const markedDedupe =
144
+ response['dedupe'] === true ||
145
+ response['existing'] === true ||
146
+ response['skipUpload'] === true ||
147
+ strategy === 'dedupe'
148
+
149
+ if (!markedDedupe) return null
150
+
151
+ const fileId =
152
+ typeof response['fileId'] === 'string'
153
+ ? response['fileId']
154
+ : typeof response['id'] === 'string'
155
+ ? response['id']
156
+ : null
157
+ const key = typeof response['key'] === 'string' ? response['key'] : null
158
+ if (!fileId || !key) return null
159
+
160
+ return { fileId, key } as R
161
+ }
162
+
163
+ import {
164
+ UploadAbortError,
165
+ UploadEngineError,
166
+ UploadHttpError,
167
+ UploadNetworkError,
168
+ UploadUnknownError,
169
+ } from '../../errors'
170
+
171
+ /**
172
+ * Normalize a thrown value into an {@link Contracts.Errors.Error}. Override via
173
+ * {@link StoreOptions.errorNormalizer}. Default: aborts → non-retryable;
174
+ * network errors → retryable; HTTP 5xx/429 → retryable.
175
+ */
176
+ export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => Contracts.Errors.Error): UploadError {
60
177
  if (customNormalizer) return customNormalizer(err)
61
178
 
62
- // Transport abort errors can carry {code:'aborted', reason:'pause'|'cancel'}
179
+ if (err instanceof UploadEngineError) {
180
+ return err
181
+ }
182
+
183
+ // Transport aborts carry `{ code: 'aborted', reason: 'pause' | 'cancel' }`.
63
184
  if (isAbortError(err)) {
64
- return { code: 'aborted', message: 'Upload aborted', reason: String(err.reason ?? 'unknown'), retryable: false }
185
+ const reason = err.reason === 'pause' || err.reason === 'cancel' ? err.reason : 'unknown'
186
+ return new UploadAbortError(reason, undefined, { cause: err })
65
187
  }
66
188
 
67
- const msg = isRecord(err) && typeof err.message === 'string' ? err.message : 'Unknown error'
189
+ const msg = isRecord(err) && typeof err['message'] === 'string' ? (err['message'] as string) : 'Unknown error'
68
190
 
69
- // Network errors
70
- if (String(msg).toLowerCase().includes('network') || String(msg).toLowerCase().includes('fetch')) {
71
- return { code: 'network', message: String(msg), cause: err, retryable: true }
191
+ if (msg.toLowerCase().includes('network') || msg.toLowerCase().includes('fetch')) {
192
+ return new UploadNetworkError(msg, { cause: err })
72
193
  }
73
194
 
74
- // HTTP errors
75
- if (isRecord(err) && (typeof err.status === 'number' || typeof err.statusCode === 'number')) {
76
- const status = (typeof err.status === 'number' ? err.status : err.statusCode) as number
77
- const retryable = status >= 500 || status === 429
78
- return { code: 'http', status, message: String(msg), cause: err, retryable }
195
+ if (isRecord(err) && (typeof err['status'] === 'number' || typeof err['statusCode'] === 'number')) {
196
+ const status = (typeof err['status'] === 'number' ? err['status'] : err['statusCode']) as number
197
+ const statusText = typeof err['statusText'] === 'string' ? err['statusText'] : undefined
198
+ return new UploadHttpError(status, msg, { cause: err, statusText })
79
199
  }
80
200
 
81
- return { code: 'unknown', message: String(msg), cause: err, retryable: false }
201
+ return new UploadUnknownError(msg, { cause: err })
82
202
  }
83
203
 
84
- /**
85
- * Type guard for items that currently hold a concrete backend intent.
86
- */
87
- export function hasIntent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
88
- item: UploadItem<M, C, P, R>,
89
- ): item is Extract<UploadItem<M, C, P, R>, { intent: M[keyof M] }> {
204
+ /** Item has a concrete backend intent. */
205
+ export function hasIntent<
206
+ M extends Contracts.Intent.Map,
207
+ C extends Contracts.Cursor.Map<M>,
208
+ P extends string,
209
+ R extends Contracts.Result.Base,
210
+ >(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { intent: M[keyof M] }> {
90
211
  return 'intent' in item && !!item.intent
91
212
  }
92
213
 
93
- /**
94
- * Type guard for items that currently have a bound {@link File}.
95
- *
96
- * Persisted items restored from storage may lack `file` until the UI re-binds it.
97
- */
98
- export function hasFile<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
99
- item: UploadItem<M, C, P, R>,
100
- ): item is Extract<UploadItem<M, C, P, R>, { file: File }> {
214
+ /** Item has a bound `File`. Persisted items may lack it until the UI re-binds. */
215
+ export function hasFile<
216
+ M extends Contracts.Intent.Map,
217
+ C extends Contracts.Cursor.Map<M>,
218
+ P extends string,
219
+ R extends Contracts.Result.Base,
220
+ >(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { file: File }> {
101
221
  return 'file' in item && !!item.file
102
222
  }
103
223
 
104
- /**
105
- * Type guard that narrows to item variants carrying a cursor field.
106
- *
107
- * Cursor presence depends on the current phase and the strategy.
108
- */
109
- export function hasCursor<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
110
- item: UploadItem<M, C, P, R>,
111
- ): item is Extract<UploadItem<M, C, P, R>, { cursor?: AnyCursor<C> }> {
224
+ /** Item variant carries a cursor field (depends on phase/strategy). */
225
+ export function hasCursor<
226
+ M extends Contracts.Intent.Map,
227
+ C extends Contracts.Cursor.Map<M>,
228
+ P extends string,
229
+ R extends Contracts.Result.Base,
230
+ >(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { cursor?: Contracts.Cursor.Any<C> }> {
112
231
  return 'cursor' in item
113
232
  }
114
233
 
115
- /**
116
- * Runtime guard for multipart intent objects.
117
- *
118
- * Used for best-effort abort calls when canceling multipart uploads.
119
- */
234
+ /** Best-effort guard used to issue multipart-abort calls on cancel. */
120
235
  export function isMultipartIntent(
121
236
  intent: unknown,
122
237
  ): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
123
238
  return (
124
239
  isRecord(intent) &&
125
- intent.strategy === 'multipart' &&
126
- typeof intent.fileId === 'string' &&
127
- typeof intent.uploadId === 'string'
240
+ intent['strategy'] === 'multipart' &&
241
+ typeof intent['fileId'] === 'string' &&
242
+ typeof intent['uploadId'] === 'string'
128
243
  )
129
244
  }
130
245
 
131
- /**
132
- * Runtime guard for abort errors produced by the transport/strategy layer.
133
- */
134
246
  export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
135
- return isRecord(err) && err.code === 'aborted'
247
+ return isRecord(err) && err['code'] === 'aborted'
136
248
  }
137
249
 
138
250
  /**
139
- * Small helper to narrow unknown values to objects.
140
- */
141
-
142
- /**
143
- * Computes whether a failed operation should be retried and, if so, after what delay.
144
- *
145
- * If {@link UploadConfig.retryPolicy} is provided it is used as the source of truth.
146
- * Otherwise the default policy:
147
- * - never retries auth/validation/strategy_missing/aborted
148
- * - retries up to `maxAttempts` with exponential backoff and a fixed cap
251
+ * Decide whether to retry. Defers to `config.retryPolicy` when set.
252
+ * Default: never retries `auth`/`validation_failed`/`strategy_missing`/`aborted`;
253
+ * otherwise exponential backoff up to `maxAttempts`, capped at `DEFAULT_RETRY_DELAY_MAX_MS`.
149
254
  */
150
255
  export function retryDecision<P extends string>(
151
- config: UploadConfig<P>,
256
+ config: Engine.Config<P>,
152
257
  ctx: { phase: 'intent' | 'upload' | 'complete'; attempt: number; error: UploadError },
153
258
  ) {
154
259
  if (config.retryPolicy) return config.retryPolicy(ctx)
155
260
 
156
- // Default: retry network/server unknown errors, not auth/validation/strategy_missing
157
261
  if (ctx.error.code === 'auth') return { retryable: false }
158
262
  if (ctx.error.code === 'validation_failed') return { retryable: false }
159
263
  if (ctx.error.code === 'strategy_missing') return { retryable: false }
@@ -163,7 +267,6 @@ export function retryDecision<P extends string>(
163
267
  const retryable = ctx.attempt < maxAttempts
164
268
  if (!retryable) return { retryable: false }
165
269
 
166
- // Exponential backoff with cap
167
270
  const delayMs = Math.min(DEFAULT_RETRY_DELAY_MAX_MS, DEFAULT_RETRY_DELAY_BASE_MS * 2 ** (ctx.attempt - 1))
168
271
  return { retryable: true, delayMs }
169
272
  }
@@ -1,41 +1,31 @@
1
- import { resolveUploadConfig } from '../../client'
2
- import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
1
+ import type { Contracts } from '../../contracts'
3
2
  import { createXHRTransport } from '../../contracts/transport'
4
3
  import { deserializeSnapshot, serializeSnapshot } from '../../persistence'
5
4
  import { createTypedEmitter } from '../../utils/emitter'
6
- import type { UploadCommand } from '../commands.types'
7
- import type { UploadEventMap } from '../event-map.types'
8
- import type { InternalEvent, UploadItem } from '../internal-events.types'
9
- import { createReducer, type UploadState } from '../reducer'
5
+ import { isDevEnv } from '../../utils/guards'
6
+ import { resolveUploadConfig } from '../engine.libs'
7
+ import type { Engine } from '../engine.types'
8
+ import { createReducer } from '../reducer'
10
9
  import { cleanupOldItems } from './handlers/clean-up'
11
10
  import { scheduleWork } from './store.schedule'
12
- import type { InflightUpload, StoreOptions, StoreRuntime } from './store.types'
11
+ import type { Store } from './store.types'
13
12
 
14
13
  /**
15
- * Creates a new runtime instance with initial state, emitter, reducer, and effect queue.
16
- *
17
- * Notes:
18
- * - Reducers remain pure: all async work is enqueued through {@link StoreRuntime.enqueueEffect}.
19
- * - {@link StoreRuntime.applyInternal} invokes hooks, performs cleanup, and then schedules more work.
20
- * - {@link StoreRuntime.dispatch} is intentionally assigned later to break import cycles.
21
- *
22
- * @template M - Intent map type
23
- * @template C - Cursor map type
24
- * @template P - Purpose string union type
25
- *
26
- * @param opts - Construction options for the store.
14
+ * Build the store runtime: state, emitter, reducer, and effect queue.
15
+ * Reducers stay pure async work goes through {@link StoreRuntime.enqueueEffect}.
16
+ * `dispatch` is assigned later by `createUploadStore` to break an import cycle.
27
17
  */
28
18
  export function createStoreRuntime<
29
- M extends IntentMap,
30
- C extends CursorMap<M>,
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
31
21
  P extends string,
32
- R extends UploadResultBase = UploadResultBase,
33
- >(opts: StoreOptions<M, C, P, R>): StoreRuntime<M, C, P, R> {
22
+ R extends Contracts.Result.Base = Contracts.Result.Base,
23
+ >(opts: Store.Options<M, C, P, R>): Store.Runtime<M, C, P, R> {
34
24
  const resolvedOpts = resolveStoreOptions(opts)
35
25
  const persistence = resolvedOpts.persistence
36
26
 
37
- // Debounced persistence flush. Persistence writes a single namespace `key`,
38
- // and the adapter can choose how to store multiple item records under it.
27
+ // Debounced flush. Persistence writes under a single namespace `key`; the
28
+ // adapter decides how to encode multiple item records under that key.
39
29
  let persistTimer: ReturnType<typeof setTimeout> | null = null
40
30
  const persistDebounceMs = Math.max(0, persistence?.debounceMs ?? 200)
41
31
 
@@ -44,7 +34,7 @@ export function createStoreRuntime<
44
34
  try {
45
35
  const snap = (persistence.serialize ?? serializeSnapshot)(rt.state, persistence.version)
46
36
 
47
- // If there's nothing to persist, clear the namespace to keep storage clean.
37
+ // Nothing to persist → clear the namespace.
48
38
  if (!snap.items || Object.keys(snap.items).length === 0) {
49
39
  await persistence.adapter.clear(persistence.key)
50
40
  return
@@ -52,7 +42,7 @@ export function createStoreRuntime<
52
42
 
53
43
  await persistence.adapter.save(persistence.key, snap)
54
44
  } catch (err) {
55
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
45
+ if (isDevEnv()) {
56
46
  console.warn('[UploadEngine] persistence flush failed:', err)
57
47
  }
58
48
  }
@@ -68,21 +58,21 @@ export function createStoreRuntime<
68
58
  persistTimer = setTimeout(() => void flushPersistence(), persistDebounceMs)
69
59
  }
70
60
 
71
- const initialState: UploadState<M, C, P, R> = resolvedOpts.initialState ?? {
72
- items: new Map<string, UploadItem<M, C, P, R>>(),
61
+ const initialState: Engine.State<M, C, P, R> = resolvedOpts.initialState ?? {
62
+ items: new Map<string, Engine.Item<M, C, P, R>>(),
73
63
  }
74
64
 
75
65
  const effectQueue: Array<() => Promise<void>> = []
76
66
 
77
- const rt: StoreRuntime<M, C, P, R> = {
67
+ const rt: Store.Runtime<M, C, P, R> = {
78
68
  opts: resolvedOpts,
79
69
  state: initialState,
80
70
 
81
71
  listeners: new Set<() => void>(),
82
- emitter: createTypedEmitter<UploadEventMap<M, C, P, R>>(),
72
+ emitter: createTypedEmitter<Engine.EventMap<M, C, P, R>>(),
83
73
  reduce: createReducer<M, C, P, R>(),
84
74
 
85
- inflightUploads: new Map<string, InflightUpload>(),
75
+ inflightUploads: new Map<string, Store.InflightUpload>(),
86
76
  inflightIntents: new Map<string, AbortController>(),
87
77
  inflightCompletes: new Map<string, AbortController>(),
88
78
 
@@ -100,7 +90,7 @@ export function createStoreRuntime<
100
90
  scheduleWork(rt)
101
91
  },
102
92
 
103
- applyInternal(event: InternalEvent<M, C, P, R>) {
93
+ applyInternal(event: Engine.Event<M, C, P, R>) {
104
94
  const prev = rt.state
105
95
  const next = rt.reduce(prev, event)
106
96
  rt.state = next
@@ -116,7 +106,7 @@ export function createStoreRuntime<
116
106
  rt.scheduleWork()
117
107
  },
118
108
 
119
- applyCommand(cmd: UploadCommand<P>) {
109
+ applyCommand(cmd: Engine.Command<P>) {
120
110
  const prev = rt.state
121
111
  const next = rt.reduce(prev, cmd)
122
112
  rt.state = next
@@ -144,7 +134,7 @@ export function createStoreRuntime<
144
134
  try {
145
135
  await effect()
146
136
  } catch (err) {
147
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
137
+ if (isDevEnv()) {
148
138
  const error = err instanceof Error ? err : new Error(String(err))
149
139
  const context = {
150
140
  queueLength: rt.effectQueue.length,
@@ -158,14 +148,15 @@ export function createStoreRuntime<
158
148
  rt.processingEffects = false
159
149
  },
160
150
 
161
- // replaced by createUploadStore
151
+ // Replaced by createUploadStore runtime breaks the cycle.
162
152
  dispatch: () => {
163
153
  throw new Error('[UploadEngine] dispatch not initialized')
164
154
  },
165
- } satisfies StoreRuntime<M, C, P, R>
155
+ } satisfies Store.Runtime<M, C, P, R>
166
156
 
167
- // Hydrate from persistence when no initialState was provided.
168
- // For async adapters (IndexedDB), this happens after construction.
157
+ // Async adapters (IndexedDB) resolve after construction; only hydrate when no
158
+ // initialState was provided. Merge into an empty store only never clobber
159
+ // user actions that arrived before the load resolved.
169
160
  if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
170
161
  try {
171
162
  const loaded = persistence.adapter.load(persistence.key)
@@ -178,8 +169,6 @@ export function createStoreRuntime<
178
169
  })
179
170
  if (!next) return
180
171
 
181
- // Only merge into an empty store to avoid clobbering user actions
182
- // that happened before the async load completed.
183
172
  if (rt.state.items.size === 0) {
184
173
  rt.state = next
185
174
  } else {
@@ -205,9 +194,12 @@ export function createStoreRuntime<
205
194
  return rt
206
195
  }
207
196
 
208
- function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
209
- opts: StoreOptions<M, C, P, R>,
210
- ): StoreRuntime<M, C, P, R>['opts'] {
197
+ function resolveStoreOptions<
198
+ M extends Contracts.Intent.Map,
199
+ C extends Contracts.Cursor.Map<M>,
200
+ P extends string,
201
+ R extends Contracts.Result.Base,
202
+ >(opts: Store.Options<M, C, P, R>): Store.Runtime<M, C, P, R>['opts'] {
211
203
  return {
212
204
  ...opts,
213
205
  config: resolveUploadConfig(opts.config),
@@ -215,10 +207,12 @@ function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P exte
215
207
  }
216
208
  }
217
209
 
218
- function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
219
- rt: StoreRuntime<M, C, P, R>,
220
- event: InternalEvent<M, C, P, R>,
221
- ) {
210
+ function emitInternalEvent<
211
+ M extends Contracts.Intent.Map,
212
+ C extends Contracts.Cursor.Map<M>,
213
+ P extends string,
214
+ R extends Contracts.Result.Base,
215
+ >(rt: Store.Runtime<M, C, P, R>, event: Engine.Event<M, C, P, R>) {
222
216
  switch (event.type) {
223
217
  case 'files.added': {
224
218
  for (const item of event.items) {
@@ -308,11 +302,16 @@ function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extend
308
302
  }
309
303
  }
310
304
 
311
- function emitCommandEvents<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
312
- rt: StoreRuntime<M, C, P, R>,
313
- cmd: UploadCommand<P>,
314
- prev: UploadState<M, C, P, R>,
315
- next: UploadState<M, C, P, R>,
305
+ function emitCommandEvents<
306
+ M extends Contracts.Intent.Map,
307
+ C extends Contracts.Cursor.Map<M>,
308
+ P extends string,
309
+ R extends Contracts.Result.Base,
310
+ >(
311
+ rt: Store.Runtime<M, C, P, R>,
312
+ cmd: Engine.Command<P>,
313
+ prev: Engine.State<M, C, P, R>,
314
+ next: Engine.State<M, C, P, R>,
316
315
  ) {
317
316
  if (cmd.type === 'start' || cmd.type === 'resume') {
318
317
  const prevItem = prev.items.get(cmd.localId)