@gentleduck/upload 0.2.1

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 (68) hide show
  1. package/dist/index.d.ts +1417 -0
  2. package/dist/index.js +6 -0
  3. package/package.json +43 -0
  4. package/src/core/client/config.types.ts +77 -0
  5. package/src/core/client/index.ts +4 -0
  6. package/src/core/client/plugins.types.ts +46 -0
  7. package/src/core/client/retry.types.ts +6 -0
  8. package/src/core/client/uploadClient.ts +19 -0
  9. package/src/core/contracts/backendApi.ts +62 -0
  10. package/src/core/contracts/cursor.types.ts +23 -0
  11. package/src/core/contracts/errors.types.ts +67 -0
  12. package/src/core/contracts/fingerprint.types.ts +18 -0
  13. package/src/core/contracts/index.ts +8 -0
  14. package/src/core/contracts/intent.types.ts +39 -0
  15. package/src/core/contracts/result.types.ts +11 -0
  16. package/src/core/contracts/strategy.types.ts +45 -0
  17. package/src/core/contracts/transport/index.ts +2 -0
  18. package/src/core/contracts/transport/transport.libs.ts +100 -0
  19. package/src/core/contracts/transport/transport.ts +166 -0
  20. package/src/core/contracts/transport/transport.types.ts +44 -0
  21. package/src/core/engine/commands.types.ts +17 -0
  22. package/src/core/engine/event-map.types.ts +45 -0
  23. package/src/core/engine/index.ts +8 -0
  24. package/src/core/engine/internal-events.types.ts +222 -0
  25. package/src/core/engine/outcome.types.ts +9 -0
  26. package/src/core/engine/progress.types.ts +15 -0
  27. package/src/core/engine/reducer.ts +435 -0
  28. package/src/core/engine/store/handlers/add-file.ts +103 -0
  29. package/src/core/engine/store/handlers/cancel.ts +56 -0
  30. package/src/core/engine/store/handlers/clean-up.ts +66 -0
  31. package/src/core/engine/store/handlers/create-intent.ts +76 -0
  32. package/src/core/engine/store/handlers/finalize.ts +59 -0
  33. package/src/core/engine/store/handlers/index.ts +7 -0
  34. package/src/core/engine/store/handlers/pause.ts +16 -0
  35. package/src/core/engine/store/handlers/run-upload.ts +192 -0
  36. package/src/core/engine/store/index.ts +123 -0
  37. package/src/core/engine/store/store.dispatch.ts +78 -0
  38. package/src/core/engine/store/store.libs.ts +169 -0
  39. package/src/core/engine/store/store.runtime.ts +336 -0
  40. package/src/core/engine/store/store.schedule.ts +116 -0
  41. package/src/core/engine/store/store.types.ts +250 -0
  42. package/src/core/engine/validation/file.ts +77 -0
  43. package/src/core/engine/validation/index.ts +3 -0
  44. package/src/core/engine/validation/intent.ts +55 -0
  45. package/src/core/engine/validation/list.ts +50 -0
  46. package/src/core/index.ts +5 -0
  47. package/src/core/persistence/adapters.indexeddb.ts +92 -0
  48. package/src/core/persistence/adapters.local.ts +22 -0
  49. package/src/core/persistence/adapters.memory.ts +15 -0
  50. package/src/core/persistence/index.ts +5 -0
  51. package/src/core/persistence/persistence.ts +194 -0
  52. package/src/core/persistence/persistence.types.ts +66 -0
  53. package/src/core/utils/async.ts +3 -0
  54. package/src/core/utils/constants.ts +32 -0
  55. package/src/core/utils/emitter/emitter.types.ts +23 -0
  56. package/src/core/utils/emitter/index.ts +68 -0
  57. package/src/core/utils/fingerprint.ts +20 -0
  58. package/src/core/utils/guards.ts +3 -0
  59. package/src/core/utils/id.ts +3 -0
  60. package/src/core/utils/index.ts +6 -0
  61. package/src/index.ts +3 -0
  62. package/src/react/index.ts +2 -0
  63. package/src/react/upload-provider.tsx +96 -0
  64. package/src/react/use-uploader.tsx +115 -0
  65. package/src/strategies/index.ts +15 -0
  66. package/src/strategies/multipart/index.ts +243 -0
  67. package/src/strategies/post/index.ts +93 -0
  68. package/src/strategies/registry/index.ts +30 -0
@@ -0,0 +1,169 @@
1
+ import type { UploadConfig } from '../../client'
2
+ import type { AnyCursor, CursorMap, IntentMap, UploadError, UploadResultBase } from '../../contracts'
3
+ import { DEFAULT_RETRY_DELAY_BASE_MS, DEFAULT_RETRY_DELAY_MAX_MS } from '../../utils/constants'
4
+ import { computeFingerprint, fingerprintMatches } from '../../utils/fingerprint'
5
+ import { isRecord } from '../../utils/guards'
6
+ import type { UploadItem } from '../internal-events.types'
7
+
8
+ export { sleep } from '../../utils/async'
9
+
10
+ import type { StoreOptions } from './store.types'
11
+
12
+ /**
13
+ * Calculates SHA256 checksum of a file for deduplication.
14
+ * Uses Web Crypto API for efficient hashing.
15
+ */
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
+ }
22
+
23
+ /**
24
+ * Determines whether items for a given purpose should auto-start.
25
+ *
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
30
+ */
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 {
35
+ const v = opts.config.autoStart
36
+ if (v === undefined) return false
37
+ if (Array.isArray(v)) return v.includes(purpose)
38
+ if (typeof v === 'function') return v(purpose)
39
+ return false
40
+ }
41
+
42
+ /**
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.
48
+ */
49
+ export { computeFingerprint, fingerprintMatches }
50
+
51
+ /**
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
58
+ */
59
+ export function normalizeError(err: unknown, customNormalizer?: (err: unknown) => UploadError): UploadError {
60
+ if (customNormalizer) return customNormalizer(err)
61
+
62
+ // Transport abort errors can carry {code:'aborted', reason:'pause'|'cancel'}
63
+ if (isAbortError(err)) {
64
+ return { code: 'aborted', message: 'Upload aborted', reason: String(err.reason ?? 'unknown'), retryable: false }
65
+ }
66
+
67
+ const msg = isRecord(err) && typeof err.message === 'string' ? err.message : 'Unknown error'
68
+
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 }
72
+ }
73
+
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
77
+ const retryable = status >= 500 || status === 429
78
+ return { code: 'http', status, message: String(msg), cause: err, retryable }
79
+ }
80
+
81
+ return { code: 'unknown', message: String(msg), cause: err, retryable: false }
82
+ }
83
+
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] }> {
90
+ return 'intent' in item && !!item.intent
91
+ }
92
+
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 }> {
101
+ return 'file' in item && !!item.file
102
+ }
103
+
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> }> {
112
+ return 'cursor' in item
113
+ }
114
+
115
+ /**
116
+ * Runtime guard for multipart intent objects.
117
+ *
118
+ * Used for best-effort abort calls when canceling multipart uploads.
119
+ */
120
+ export function isMultipartIntent(
121
+ intent: unknown,
122
+ ): intent is { strategy: 'multipart'; fileId: string; uploadId: string; partSize: number } {
123
+ return (
124
+ isRecord(intent) &&
125
+ intent.strategy === 'multipart' &&
126
+ typeof intent.fileId === 'string' &&
127
+ typeof intent.uploadId === 'string'
128
+ )
129
+ }
130
+
131
+ /**
132
+ * Runtime guard for abort errors produced by the transport/strategy layer.
133
+ */
134
+ export function isAbortError(err: unknown): err is { code: 'aborted'; reason?: unknown } {
135
+ return isRecord(err) && err.code === 'aborted'
136
+ }
137
+
138
+ /**
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
149
+ */
150
+ export function retryDecision<P extends string>(
151
+ config: UploadConfig<P>,
152
+ ctx: { phase: 'intent' | 'upload' | 'complete'; attempt: number; error: UploadError },
153
+ ) {
154
+ if (config.retryPolicy) return config.retryPolicy(ctx)
155
+
156
+ // Default: retry network/server unknown errors, not auth/validation/strategy_missing
157
+ if (ctx.error.code === 'auth') return { retryable: false }
158
+ if (ctx.error.code === 'validation_failed') return { retryable: false }
159
+ if (ctx.error.code === 'strategy_missing') return { retryable: false }
160
+ if (ctx.error.code === 'aborted') return { retryable: false }
161
+
162
+ const maxAttempts = config.maxAttempts
163
+ const retryable = ctx.attempt < maxAttempts
164
+ if (!retryable) return { retryable: false }
165
+
166
+ // Exponential backoff with cap
167
+ const delayMs = Math.min(DEFAULT_RETRY_DELAY_MAX_MS, DEFAULT_RETRY_DELAY_BASE_MS * 2 ** (ctx.attempt - 1))
168
+ return { retryable: true, delayMs }
169
+ }
@@ -0,0 +1,336 @@
1
+ import { resolveUploadConfig } from '../../client'
2
+ import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
3
+ import { createXHRTransport } from '../../contracts/transport'
4
+ import { deserializeSnapshot, serializeSnapshot } from '../../persistence'
5
+ 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'
10
+ import { cleanupOldItems } from './handlers/clean-up'
11
+ import { scheduleWork } from './store.schedule'
12
+ import type { InflightUpload, StoreOptions, StoreRuntime } from './store.types'
13
+
14
+ /**
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.
27
+ */
28
+ export function createStoreRuntime<
29
+ M extends IntentMap,
30
+ C extends CursorMap<M>,
31
+ P extends string,
32
+ R extends UploadResultBase = UploadResultBase,
33
+ >(opts: StoreOptions<M, C, P, R>): StoreRuntime<M, C, P, R> {
34
+ const resolvedOpts = resolveStoreOptions(opts)
35
+ const persistence = resolvedOpts.persistence
36
+
37
+ // Debounced persistence flush. Persistence writes a single namespace `key`,
38
+ // and the adapter can choose how to store multiple item records under it.
39
+ let persistTimer: ReturnType<typeof setTimeout> | null = null
40
+ const persistDebounceMs = Math.max(0, persistence?.debounceMs ?? 200)
41
+
42
+ const flushPersistence = async () => {
43
+ if (!persistence) return
44
+ try {
45
+ const snap = (persistence.serialize ?? serializeSnapshot)(rt.state, persistence.version)
46
+
47
+ // If there's nothing to persist, clear the namespace to keep storage clean.
48
+ if (!snap.items || Object.keys(snap.items).length === 0) {
49
+ await persistence.adapter.clear(persistence.key)
50
+ return
51
+ }
52
+
53
+ await persistence.adapter.save(persistence.key, snap)
54
+ } catch (err) {
55
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
56
+ console.warn('[UploadEngine] persistence flush failed:', err)
57
+ }
58
+ }
59
+ }
60
+
61
+ const schedulePersistence = () => {
62
+ if (!persistence) return
63
+ if (persistDebounceMs === 0) {
64
+ void flushPersistence()
65
+ return
66
+ }
67
+ if (persistTimer) clearTimeout(persistTimer)
68
+ persistTimer = setTimeout(() => void flushPersistence(), persistDebounceMs)
69
+ }
70
+
71
+ const initialState: UploadState<M, C, P, R> = resolvedOpts.initialState ?? {
72
+ items: new Map<string, UploadItem<M, C, P, R>>(),
73
+ }
74
+
75
+ const effectQueue: Array<() => Promise<void>> = []
76
+
77
+ const rt: StoreRuntime<M, C, P, R> = {
78
+ opts: resolvedOpts,
79
+ state: initialState,
80
+
81
+ listeners: new Set<() => void>(),
82
+ emitter: createTypedEmitter<UploadEventMap<M, C, P, R>>(),
83
+ reduce: createReducer<M, C, P, R>(),
84
+
85
+ inflightUploads: new Map<string, InflightUpload>(),
86
+ inflightIntents: new Map<string, AbortController>(),
87
+ inflightCompletes: new Map<string, AbortController>(),
88
+
89
+ effectQueue,
90
+ processingEffects: false,
91
+ scheduling: false,
92
+
93
+ notify() {
94
+ rt.listeners.forEach((l) => l())
95
+ },
96
+
97
+ scheduleWork() {
98
+ scheduleWork(rt)
99
+ },
100
+
101
+ applyInternal(event: InternalEvent<M, C, P, R>) {
102
+ const prev = rt.state
103
+ const next = rt.reduce(prev, event)
104
+ rt.state = next
105
+ rt.opts.hooks?.onInternalEvent?.(event, rt.state)
106
+
107
+ emitInternalEvent(rt, event)
108
+
109
+ const cleaned = cleanupOldItems(rt.opts, rt.state)
110
+ if (cleaned) rt.state = cleaned
111
+
112
+ schedulePersistence()
113
+ rt.notify()
114
+ rt.scheduleWork()
115
+ },
116
+
117
+ applyCommand(cmd: UploadCommand<P>) {
118
+ const prev = rt.state
119
+ const next = rt.reduce(prev, cmd)
120
+ rt.state = next
121
+
122
+ emitCommandEvents(rt, cmd, prev, next)
123
+
124
+ const cleaned = cleanupOldItems(rt.opts, rt.state)
125
+ if (cleaned) rt.state = cleaned
126
+
127
+ schedulePersistence()
128
+ rt.notify()
129
+ rt.scheduleWork()
130
+ },
131
+
132
+ enqueueEffect(effect: () => Promise<void>) {
133
+ rt.effectQueue.push(effect)
134
+ if (!rt.processingEffects) void rt.processEffects()
135
+ },
136
+
137
+ async processEffects() {
138
+ rt.processingEffects = true
139
+ while (rt.effectQueue.length > 0) {
140
+ const effect = rt.effectQueue.shift()
141
+ if (!effect) continue
142
+ try {
143
+ await effect()
144
+ } catch (err) {
145
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
146
+ const error = err instanceof Error ? err : new Error(String(err))
147
+ const context = {
148
+ queueLength: rt.effectQueue.length,
149
+ processing: rt.processingEffects,
150
+ timestamp: new Date().toISOString(),
151
+ }
152
+ console.error('[UploadEngine] Effect error:', error, context)
153
+ }
154
+ }
155
+ }
156
+ rt.processingEffects = false
157
+ },
158
+
159
+ // replaced by createUploadStore
160
+ dispatch: () => {
161
+ throw new Error('[UploadEngine] dispatch not initialized')
162
+ },
163
+ } satisfies StoreRuntime<M, C, P, R>
164
+
165
+ // Hydrate from persistence when no initialState was provided.
166
+ // For async adapters (IndexedDB), this happens after construction.
167
+ if (persistence && !resolvedOpts.initialState && typeof window !== 'undefined') {
168
+ try {
169
+ const loaded = persistence.adapter.load(persistence.key)
170
+ const applyLoaded = (raw: unknown | null) => {
171
+ if (!raw) return
172
+ const next = (persistence.deserialize ?? deserializeSnapshot)(raw, {
173
+ isPurpose: persistence.isPurpose,
174
+ isIntent: persistence.isIntent,
175
+ hasStrategy: resolvedOpts.strategies.has,
176
+ })
177
+ if (!next) return
178
+
179
+ // Only merge into an empty store to avoid clobbering user actions
180
+ // that happened before the async load completed.
181
+ if (rt.state.items.size === 0) {
182
+ rt.state = next
183
+ } else {
184
+ const merged = new Map(rt.state.items)
185
+ for (const [id, item] of next.items.entries()) {
186
+ if (!merged.has(id)) merged.set(id, item)
187
+ }
188
+ rt.state = { ...rt.state, items: merged }
189
+ }
190
+
191
+ rt.notify()
192
+ rt.scheduleWork()
193
+ }
194
+
195
+ void Promise.resolve(loaded)
196
+ .then(applyLoaded)
197
+ .catch(() => undefined)
198
+ } catch {
199
+ // ignore
200
+ }
201
+ }
202
+
203
+ return rt
204
+ }
205
+
206
+ function resolveStoreOptions<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
207
+ opts: StoreOptions<M, C, P, R>,
208
+ ): StoreRuntime<M, C, P, R>['opts'] {
209
+ return {
210
+ ...opts,
211
+ config: resolveUploadConfig(opts.config),
212
+ transport: opts.transport ?? createXHRTransport(),
213
+ }
214
+ }
215
+
216
+ function emitInternalEvent<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
217
+ rt: StoreRuntime<M, C, P, R>,
218
+ event: InternalEvent<M, C, P, R>,
219
+ ) {
220
+ switch (event.type) {
221
+ case 'files.added': {
222
+ for (const item of event.items) {
223
+ rt.emitter.emit('file.added', {
224
+ localId: item.localId,
225
+ purpose: item.purpose,
226
+ file: item.file,
227
+ fingerprint: item.fingerprint,
228
+ })
229
+ }
230
+ break
231
+ }
232
+ case 'validation.ok': {
233
+ rt.emitter.emit('validation.ok', { localId: event.localId })
234
+ rt.emitter.emit('intent.creating', { localId: event.localId })
235
+ break
236
+ }
237
+ case 'validation.failed': {
238
+ rt.emitter.emit('validation.failed', { localId: event.localId, reason: event.reason })
239
+ break
240
+ }
241
+ case 'intent.ok': {
242
+ rt.emitter.emit('intent.created', { localId: event.localId, intent: event.intent })
243
+ break
244
+ }
245
+ case 'intent.failed': {
246
+ rt.emitter.emit('intent.failed', { localId: event.localId, error: event.error, retryable: event.retryable })
247
+ break
248
+ }
249
+ case 'upload.begin': {
250
+ rt.emitter.emit('upload.started', { localId: event.localId })
251
+ break
252
+ }
253
+ case 'upload.progress': {
254
+ const pct = event.totalBytes > 0 ? Math.min(100, (event.uploadedBytes / event.totalBytes) * 100) : 0
255
+ rt.emitter.emit('upload.progress', {
256
+ localId: event.localId,
257
+ pct,
258
+ uploadedBytes: event.uploadedBytes,
259
+ totalBytes: event.totalBytes,
260
+ })
261
+ break
262
+ }
263
+ case 'cursor.updated': {
264
+ rt.emitter.emit('upload.cursor', { localId: event.localId, cursor: event.cursor })
265
+ break
266
+ }
267
+ case 'upload.ok': {
268
+ rt.emitter.emit('upload.completing', { localId: event.localId })
269
+ break
270
+ }
271
+ case 'upload.failed': {
272
+ rt.emitter.emit('upload.error', { localId: event.localId, error: event.error, retryable: event.retryable })
273
+ break
274
+ }
275
+ case 'dedupe.ok': {
276
+ rt.emitter.emit('upload.completed', {
277
+ localId: event.localId,
278
+ result: event.result,
279
+ completedBy: 'dedupe',
280
+ })
281
+ break
282
+ }
283
+ case 'complete.ok': {
284
+ rt.emitter.emit('upload.completed', {
285
+ localId: event.localId,
286
+ result: event.result,
287
+ completedBy: 'upload',
288
+ })
289
+ break
290
+ }
291
+ case 'complete.failed': {
292
+ rt.emitter.emit('upload.error', { localId: event.localId, error: event.error, retryable: event.retryable })
293
+ break
294
+ }
295
+ case 'paused': {
296
+ rt.emitter.emit('upload.paused', { localId: event.localId, cursor: event.cursor })
297
+ break
298
+ }
299
+ case 'canceled': {
300
+ rt.emitter.emit('upload.canceled', { localId: event.localId })
301
+ break
302
+ }
303
+ case 'fingerprint.updated': {
304
+ break
305
+ }
306
+ }
307
+ }
308
+
309
+ function emitCommandEvents<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
310
+ rt: StoreRuntime<M, C, P, R>,
311
+ cmd: UploadCommand<P>,
312
+ prev: UploadState<M, C, P, R>,
313
+ next: UploadState<M, C, P, R>,
314
+ ) {
315
+ if (cmd.type === 'start' || cmd.type === 'resume') {
316
+ const prevItem = prev.items.get(cmd.localId)
317
+ const nextItem = next.items.get(cmd.localId)
318
+ if (!prevItem || !nextItem) return
319
+ if (nextItem.phase !== 'queued') return
320
+
321
+ if (prevItem.phase === 'paused') {
322
+ rt.emitter.emit('upload.resumed', { localId: cmd.localId })
323
+ } else {
324
+ rt.emitter.emit('upload.queued', { localId: cmd.localId })
325
+ }
326
+ }
327
+
328
+ if (cmd.type === 'cancel') {
329
+ const prevItem = prev.items.get(cmd.localId)
330
+ const nextItem = next.items.get(cmd.localId)
331
+ if (!prevItem || !nextItem) return
332
+ if (nextItem.phase === 'canceled' && prevItem.phase !== 'canceled') {
333
+ rt.emitter.emit('upload.canceled', { localId: cmd.localId })
334
+ }
335
+ }
336
+ }
@@ -0,0 +1,116 @@
1
+ import type { CursorMap, IntentMap, UploadResultBase } from '../../contracts'
2
+ import type { UploadItem } from '../internal-events.types'
3
+ import { createIntent } from './handlers/create-intent'
4
+ import { finalizeUpload } from './handlers/finalize'
5
+ import { runUpload } from './handlers/run-upload'
6
+ import { isAutoStart } from './store.libs'
7
+ import type { StoreRuntime } from './store.types'
8
+
9
+ /**
10
+ * Central scheduler that ensures the system is always making progress towards a consistent state.
11
+ * It checks for:
12
+ * 1. Pending intent creations
13
+ * 2. Available upload slots (concurrency limits)
14
+ * 3. Pending completions
15
+ *
16
+ * This function is idempotent and safe to call multiple times.
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
+ ) {
21
+ if (rt.scheduling) return
22
+ rt.scheduling = true
23
+ try {
24
+ scheduleIntentCreations(rt)
25
+ scheduleUploads(rt)
26
+ scheduleCompletes(rt)
27
+ } finally {
28
+ rt.scheduling = false
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Enqueues intent creation for items that are in the `creating_intent` phase.
34
+ *
35
+ * The intent creation itself runs as an async effect and is guarded by
36
+ * {@link StoreRuntime.inflightIntents}.
37
+ */
38
+ export function scheduleIntentCreations<
39
+ M extends IntentMap,
40
+ C extends CursorMap<M>,
41
+ P extends string,
42
+ R extends UploadResultBase,
43
+ >(rt: StoreRuntime<M, C, P, R>) {
44
+ for (const item of rt.state.items.values()) {
45
+ if (item.phase !== 'creating_intent') continue
46
+ if (rt.inflightIntents.has(item.localId)) continue
47
+ rt.enqueueEffect(() => createIntent(rt, item.localId))
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Enqueues completion/finalization calls for items in the `completing` phase.
53
+ *
54
+ * Finalization runs as an async effect and is guarded by {@link StoreRuntime.inflightCompletes}.
55
+ */
56
+ export function scheduleCompletes<
57
+ M extends IntentMap,
58
+ C extends CursorMap<M>,
59
+ P extends string,
60
+ R extends UploadResultBase,
61
+ >(rt: StoreRuntime<M, C, P, R>) {
62
+ for (const item of rt.state.items.values()) {
63
+ if (item.phase !== 'completing') continue
64
+ if (rt.inflightCompletes.has(item.localId)) continue
65
+ rt.enqueueEffect(() => finalizeUpload(rt, item.localId))
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Manages upload concurrency and starts new uploads when slots are available.
71
+ * Respects `maxConcurrentUploads` from config.
72
+ */
73
+ export function scheduleUploads<
74
+ M extends IntentMap,
75
+ C extends CursorMap<M>,
76
+ P extends string,
77
+ R extends UploadResultBase,
78
+ >(rt: StoreRuntime<M, C, P, R>) {
79
+ const maxConcurrent = Math.max(1, rt.opts.config.maxConcurrentUploads)
80
+ const active = rt.inflightUploads.size
81
+
82
+ // Auto-start items
83
+ for (const item of rt.state.items.values()) {
84
+ if (item.phase === 'ready' && isAutoStart(rt.opts, item.purpose)) {
85
+ rt.applyCommand({ type: 'start', localId: item.localId })
86
+ }
87
+ }
88
+
89
+ if (active >= maxConcurrent) return
90
+
91
+ // Find queued items
92
+ const candidates = Array.from(rt.state.items.values()).filter(isQueuedItem)
93
+ if (candidates.length === 0) return
94
+
95
+ const slots = Math.max(0, maxConcurrent - active)
96
+ const toStart = candidates.slice(0, slots)
97
+
98
+ for (const item of toStart) {
99
+ // Guard against rare scheduling races.
100
+ if (rt.inflightUploads.has(item.localId)) continue
101
+
102
+ const controller = new AbortController()
103
+ rt.inflightUploads.set(item.localId, { controller, mode: 'normal', started: false })
104
+
105
+ rt.applyInternal({ type: 'upload.begin', localId: item.localId, startedAt: Date.now() })
106
+
107
+ // Fire-and-forget: runUpload is responsible for updating state and cleaning inflight entries.
108
+ void runUpload(rt, item.localId)
109
+ }
110
+ }
111
+
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
+ return item.phase === 'queued'
116
+ }