@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,34 +1,86 @@
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
+ }
34
+
35
+ /**
36
+ * Default boundary between inline and incremental hashing when
37
+ * `config.checksumMaxSize` is unset (`null`) or `0`: 64 MiB.
38
+ */
39
+ export const DEFAULT_CHECKSUM_MAX_SIZE = 64 * 1024 * 1024
11
40
 
12
- /** SHA-256 checksum of `file` for deduplication. */
13
- export async function calculateFileChecksum(file: File): Promise<string> {
14
- const buffer = await file.arrayBuffer()
15
- const hashBuffer = await crypto.subtle.digest('SHA-256', buffer)
16
- const hashArray = Array.from(new Uint8Array(hashBuffer))
17
- return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
41
+ /**
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.
49
+ *
50
+ * Pass `maxSize = 0` or omit it to use {@link DEFAULT_CHECKSUM_MAX_SIZE}.
51
+ *
52
+ * @returns lowercase hex SHA-256 digest.
53
+ */
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 })
18
60
  }
19
61
 
20
62
  /**
21
63
  * Resolve auto-start config: `false | undefined` (never), `P[]` (in list), or
22
64
  * `(purpose) => boolean` predicate.
23
65
  */
24
- export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
25
- opts: StoreOptions<M, C, P, R>,
26
- purpose: P,
27
- ): boolean {
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 {
28
72
  const v = opts.config?.autoStart
29
73
  if (v === undefined) return false
30
74
  if (Array.isArray(v)) return v.includes(purpose)
31
- 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
+ }
32
84
  return false
33
85
  }
34
86
 
@@ -40,51 +92,142 @@ export function isAutoStart<M extends IntentMap, C extends CursorMap<M>, P exten
40
92
  export { computeFingerprint, fingerprintMatches }
41
93
 
42
94
  /**
43
- * Normalize a thrown value into an {@link UploadError}. Override via
95
+ * When `findByChecksum` matches, complete the item without creating an intent or
96
+ * uploading bytes. Returns `true` when dedupe succeeded.
97
+ */
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
44
173
  * {@link StoreOptions.errorNormalizer}. Default: aborts → non-retryable;
45
174
  * network errors → retryable; HTTP 5xx/429 → retryable.
46
175
  */
47
- export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => UploadError): UploadError {
176
+ export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => Contracts.Errors.Error): UploadError {
48
177
  if (customNormalizer) return customNormalizer(err)
49
178
 
179
+ if (err instanceof UploadEngineError) {
180
+ return err
181
+ }
182
+
50
183
  // Transport aborts carry `{ code: 'aborted', reason: 'pause' | 'cancel' }`.
51
184
  if (isAbortError(err)) {
52
- 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 })
53
187
  }
54
188
 
55
- 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'
56
190
 
57
- if (String(msg).toLowerCase().includes('network') || String(msg).toLowerCase().includes('fetch')) {
58
- 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 })
59
193
  }
60
194
 
61
- if (isRecord(err) && (typeof err.status === 'number' || typeof err.statusCode === 'number')) {
62
- const status = (typeof err.status === 'number' ? err.status : err.statusCode) as number
63
- const retryable = status >= 500 || status === 429
64
- 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 })
65
199
  }
66
200
 
67
- return { code: 'unknown', message: String(msg), cause: err, retryable: false }
201
+ return new UploadUnknownError(msg, { cause: err })
68
202
  }
69
203
 
70
204
  /** Item has a concrete backend intent. */
71
- export function hasIntent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
72
- item: UploadItem<M, C, P, R>,
73
- ): item is Extract<UploadItem<M, C, P, R>, { intent: M[keyof M] }> {
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] }> {
74
211
  return 'intent' in item && !!item.intent
75
212
  }
76
213
 
77
214
  /** Item has a bound `File`. Persisted items may lack it until the UI re-binds. */
78
- export function hasFile<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
79
- item: UploadItem<M, C, P, R>,
80
- ): item is Extract<UploadItem<M, C, P, R>, { file: File }> {
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 }> {
81
221
  return 'file' in item && !!item.file
82
222
  }
83
223
 
84
224
  /** Item variant carries a cursor field (depends on phase/strategy). */
85
- export function hasCursor<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
86
- item: UploadItem<M, C, P, R>,
87
- ): item is Extract<UploadItem<M, C, P, R>, { cursor?: AnyCursor<C> }> {
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> }> {
88
231
  return 'cursor' in item
89
232
  }
90
233
 
@@ -94,14 +237,14 @@ export function isMultipartIntent(
94
237
  ): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
95
238
  return (
96
239
  isRecord(intent) &&
97
- intent.strategy === 'multipart' &&
98
- typeof intent.fileId === 'string' &&
99
- typeof intent.uploadId === 'string'
240
+ intent['strategy'] === 'multipart' &&
241
+ typeof intent['fileId'] === 'string' &&
242
+ typeof intent['uploadId'] === 'string'
100
243
  )
101
244
  }
102
245
 
103
246
  export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
104
- return isRecord(err) && err.code === 'aborted'
247
+ return isRecord(err) && err['code'] === 'aborted'
105
248
  }
106
249
 
107
250
  /**
@@ -110,7 +253,7 @@ export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: u
110
253
  * otherwise exponential backoff up to `maxAttempts`, capped at `DEFAULT_RETRY_DELAY_MAX_MS`.
111
254
  */
112
255
  export function retryDecision<P extends string>(
113
- config: UploadConfig<P>,
256
+ config: Engine.Config<P>,
114
257
  ctx: { phase: 'intent' | 'upload' | 'complete'; attempt: number; error: UploadError },
115
258
  ) {
116
259
  if (config.retryPolicy) return config.retryPolicy(ctx)
@@ -1,27 +1,26 @@
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
14
  * Build the store runtime: state, emitter, reducer, and effect queue.
16
- * Reducers stay pure — async work goes through {@link StoreRuntime.enqueueEffect}.
15
+ * Reducers stay pure async work goes through {@link StoreRuntime.enqueueEffect}.
17
16
  * `dispatch` is assigned later by `createUploadStore` to break an import cycle.
18
17
  */
19
18
  export function createStoreRuntime<
20
- M extends IntentMap,
21
- C extends CursorMap<M>,
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
22
21
  P extends string,
23
- R extends UploadResultBase = UploadResultBase,
24
- >(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> {
25
24
  const resolvedOpts = resolveStoreOptions(opts)
26
25
  const persistence = resolvedOpts.persistence
27
26
 
@@ -43,7 +42,7 @@ export function createStoreRuntime<
43
42
 
44
43
  await persistence.adapter.save(persistence.key, snap)
45
44
  } catch (err) {
46
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
45
+ if (isDevEnv()) {
47
46
  console.warn('[UploadEngine] persistence flush failed:', err)
48
47
  }
49
48
  }
@@ -59,21 +58,21 @@ export function createStoreRuntime<
59
58
  persistTimer = setTimeout(() => void flushPersistence(), persistDebounceMs)
60
59
  }
61
60
 
62
- const initialState: UploadState<M, C, P, R> = resolvedOpts.initialState ?? {
63
- 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>>(),
64
63
  }
65
64
 
66
65
  const effectQueue: Array<() => Promise<void>> = []
67
66
 
68
- const rt: StoreRuntime<M, C, P, R> = {
67
+ const rt: Store.Runtime<M, C, P, R> = {
69
68
  opts: resolvedOpts,
70
69
  state: initialState,
71
70
 
72
71
  listeners: new Set<() => void>(),
73
- emitter: createTypedEmitter<UploadEventMap<M, C, P, R>>(),
72
+ emitter: createTypedEmitter<Engine.EventMap<M, C, P, R>>(),
74
73
  reduce: createReducer<M, C, P, R>(),
75
74
 
76
- inflightUploads: new Map<string, InflightUpload>(),
75
+ inflightUploads: new Map<string, Store.InflightUpload>(),
77
76
  inflightIntents: new Map<string, AbortController>(),
78
77
  inflightCompletes: new Map<string, AbortController>(),
79
78
 
@@ -91,7 +90,7 @@ export function createStoreRuntime<
91
90
  scheduleWork(rt)
92
91
  },
93
92
 
94
- applyInternal(event: InternalEvent<M, C, P, R>) {
93
+ applyInternal(event: Engine.Event<M, C, P, R>) {
95
94
  const prev = rt.state
96
95
  const next = rt.reduce(prev, event)
97
96
  rt.state = next
@@ -107,7 +106,7 @@ export function createStoreRuntime<
107
106
  rt.scheduleWork()
108
107
  },
109
108
 
110
- applyCommand(cmd: UploadCommand<P>) {
109
+ applyCommand(cmd: Engine.Command<P>) {
111
110
  const prev = rt.state
112
111
  const next = rt.reduce(prev, cmd)
113
112
  rt.state = next
@@ -135,7 +134,7 @@ export function createStoreRuntime<
135
134
  try {
136
135
  await effect()
137
136
  } catch (err) {
138
- if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
137
+ if (isDevEnv()) {
139
138
  const error = err instanceof Error ? err : new Error(String(err))
140
139
  const context = {
141
140
  queueLength: rt.effectQueue.length,
@@ -149,14 +148,14 @@ export function createStoreRuntime<
149
148
  rt.processingEffects = false
150
149
  },
151
150
 
152
- // Replaced by createUploadStore — runtime breaks the cycle.
151
+ // Replaced by createUploadStore runtime breaks the cycle.
153
152
  dispatch: () => {
154
153
  throw new Error('[UploadEngine] dispatch not initialized')
155
154
  },
156
- } satisfies StoreRuntime<M, C, P, R>
155
+ } satisfies Store.Runtime<M, C, P, R>
157
156
 
158
157
  // Async adapters (IndexedDB) resolve after construction; only hydrate when no
159
- // initialState was provided. Merge into an empty store only — never clobber
158
+ // initialState was provided. Merge into an empty store only never clobber
160
159
  // user actions that arrived before the load resolved.
161
160
  if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
162
161
  try {
@@ -195,9 +194,12 @@ export function createStoreRuntime<
195
194
  return rt
196
195
  }
197
196
 
198
- function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
199
- opts: StoreOptions<M, C, P, R>,
200
- ): 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'] {
201
203
  return {
202
204
  ...opts,
203
205
  config: resolveUploadConfig(opts.config),
@@ -205,10 +207,12 @@ function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P exte
205
207
  }
206
208
  }
207
209
 
208
- function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
209
- rt: StoreRuntime<M, C, P, R>,
210
- event: InternalEvent<M, C, P, R>,
211
- ) {
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>) {
212
216
  switch (event.type) {
213
217
  case 'files.added': {
214
218
  for (const item of event.items) {
@@ -298,11 +302,16 @@ function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extend
298
302
  }
299
303
  }
300
304
 
301
- function emitCommandEvents<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
302
- rt: StoreRuntime<M, C, P, R>,
303
- cmd: UploadCommand<P>,
304
- prev: UploadState<M, C, P, R>,
305
- 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>,
306
315
  ) {
307
316
  if (cmd.type === 'start' || cmd.type === 'resume') {
308
317
  const prevItem = prev.items.get(cmd.localId)
@@ -1,10 +1,10 @@
1
- import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
2
- import type { UploadItem } from '../internal-events.types'
1
+ import type { Contracts } from '../../contracts'
2
+ import type { Engine } from '../engine.types'
3
3
  import { createIntent } from './handlers/create-intent'
4
4
  import { finalizeUpload } from './handlers/finalize'
5
5
  import { runUpload } from './handlers/run-upload'
6
6
  import { isAutoStart } from './store.libs'
7
- import type { StoreRuntime } from './store.types'
7
+ import type { Store } from './store.types'
8
8
 
9
9
  /**
10
10
  * Central scheduler that ensures the system is always making progress towards a consistent state.
@@ -15,9 +15,12 @@ import type { StoreRuntime } from './store.types'
15
15
  *
16
16
  * This function is idempotent and safe to call multiple times.
17
17
  */
18
- export function scheduleWork<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
19
- rt: StoreRuntime<M, C, P, R>,
20
- ) {
18
+ export function scheduleWork<
19
+ M extends Contracts.Intent.Map,
20
+ C extends Contracts.Cursor.Map<M>,
21
+ P extends string,
22
+ R extends Contracts.Result.Base,
23
+ >(rt: Store.Runtime<M, C, P, R>) {
21
24
  if (rt.scheduling) return
22
25
  rt.scheduling = true
23
26
  try {
@@ -36,11 +39,11 @@ export function scheduleWork<M extends IntentMap, C extends CursorMap<M>, P exte
36
39
  * {@link StoreRuntime.inflightIntents}.
37
40
  */
38
41
  export function scheduleIntentCreations<
39
- M extends IntentMap,
40
- C extends CursorMap<M>,
42
+ M extends Contracts.Intent.Map,
43
+ C extends Contracts.Cursor.Map<M>,
41
44
  P extends string,
42
- R extends UploadResultBase,
43
- >(rt: StoreRuntime<M, C, P, R>) {
45
+ R extends Contracts.Result.Base,
46
+ >(rt: Store.Runtime<M, C, P, R>) {
44
47
  for (const item of rt.state.items.values()) {
45
48
  if (item.phase !== 'creating_intent') continue
46
49
  if (rt.inflightIntents.has(item.localId)) continue
@@ -54,11 +57,11 @@ export function scheduleIntentCreations<
54
57
  * Finalization runs as an async effect and is guarded by {@link StoreRuntime.inflightCompletes}.
55
58
  */
56
59
  export function scheduleCompletes<
57
- M extends IntentMap,
58
- C extends CursorMap<M>,
60
+ M extends Contracts.Intent.Map,
61
+ C extends Contracts.Cursor.Map<M>,
59
62
  P extends string,
60
- R extends UploadResultBase,
61
- >(rt: StoreRuntime<M, C, P, R>) {
63
+ R extends Contracts.Result.Base,
64
+ >(rt: Store.Runtime<M, C, P, R>) {
62
65
  for (const item of rt.state.items.values()) {
63
66
  if (item.phase !== 'completing') continue
64
67
  if (rt.inflightCompletes.has(item.localId)) continue
@@ -71,11 +74,11 @@ export function scheduleCompletes<
71
74
  * Respects `maxConcurrentUploads` from config.
72
75
  */
73
76
  export function scheduleUploads<
74
- M extends IntentMap,
75
- C extends CursorMap<M>,
77
+ M extends Contracts.Intent.Map,
78
+ C extends Contracts.Cursor.Map<M>,
76
79
  P extends string,
77
- R extends UploadResultBase,
78
- >(rt: StoreRuntime<M, C, P, R>) {
80
+ R extends Contracts.Result.Base,
81
+ >(rt: Store.Runtime<M, C, P, R>) {
79
82
  const maxConcurrent = Math.max(1, rt.opts.config.maxConcurrentUploads)
80
83
  const active = rt.inflightUploads.size
81
84
 
@@ -109,8 +112,11 @@ export function scheduleUploads<
109
112
  }
110
113
  }
111
114
 
112
- function isQueuedItem<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
113
- item: UploadItem<M, C, P, R>,
114
- ): item is Extract<UploadItem<M, C, P, R>, { phase: 'queued' }> {
115
+ function isQueuedItem<
116
+ M extends Contracts.Intent.Map,
117
+ C extends Contracts.Cursor.Map<M>,
118
+ P extends string,
119
+ R extends Contracts.Result.Base,
120
+ >(item: Engine.Item<M, C, P, R>): item is Extract<Engine.Item<M, C, P, R>, { phase: 'queued' }> {
115
121
  return item.phase === 'queued'
116
122
  }