@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.
- package/dist/index.d.ts +1417 -0
- package/dist/index.js +6 -0
- package/package.json +43 -0
- package/src/core/client/config.types.ts +77 -0
- package/src/core/client/index.ts +4 -0
- package/src/core/client/plugins.types.ts +46 -0
- package/src/core/client/retry.types.ts +6 -0
- package/src/core/client/uploadClient.ts +19 -0
- package/src/core/contracts/backendApi.ts +62 -0
- package/src/core/contracts/cursor.types.ts +23 -0
- package/src/core/contracts/errors.types.ts +67 -0
- package/src/core/contracts/fingerprint.types.ts +18 -0
- package/src/core/contracts/index.ts +8 -0
- package/src/core/contracts/intent.types.ts +39 -0
- package/src/core/contracts/result.types.ts +11 -0
- package/src/core/contracts/strategy.types.ts +45 -0
- package/src/core/contracts/transport/index.ts +2 -0
- package/src/core/contracts/transport/transport.libs.ts +100 -0
- package/src/core/contracts/transport/transport.ts +166 -0
- package/src/core/contracts/transport/transport.types.ts +44 -0
- package/src/core/engine/commands.types.ts +17 -0
- package/src/core/engine/event-map.types.ts +45 -0
- package/src/core/engine/index.ts +8 -0
- package/src/core/engine/internal-events.types.ts +222 -0
- package/src/core/engine/outcome.types.ts +9 -0
- package/src/core/engine/progress.types.ts +15 -0
- package/src/core/engine/reducer.ts +435 -0
- package/src/core/engine/store/handlers/add-file.ts +103 -0
- package/src/core/engine/store/handlers/cancel.ts +56 -0
- package/src/core/engine/store/handlers/clean-up.ts +66 -0
- package/src/core/engine/store/handlers/create-intent.ts +76 -0
- package/src/core/engine/store/handlers/finalize.ts +59 -0
- package/src/core/engine/store/handlers/index.ts +7 -0
- package/src/core/engine/store/handlers/pause.ts +16 -0
- package/src/core/engine/store/handlers/run-upload.ts +192 -0
- package/src/core/engine/store/index.ts +123 -0
- package/src/core/engine/store/store.dispatch.ts +78 -0
- package/src/core/engine/store/store.libs.ts +169 -0
- package/src/core/engine/store/store.runtime.ts +336 -0
- package/src/core/engine/store/store.schedule.ts +116 -0
- package/src/core/engine/store/store.types.ts +250 -0
- package/src/core/engine/validation/file.ts +77 -0
- package/src/core/engine/validation/index.ts +3 -0
- package/src/core/engine/validation/intent.ts +55 -0
- package/src/core/engine/validation/list.ts +50 -0
- package/src/core/index.ts +5 -0
- package/src/core/persistence/adapters.indexeddb.ts +92 -0
- package/src/core/persistence/adapters.local.ts +22 -0
- package/src/core/persistence/adapters.memory.ts +15 -0
- package/src/core/persistence/index.ts +5 -0
- package/src/core/persistence/persistence.ts +194 -0
- package/src/core/persistence/persistence.types.ts +66 -0
- package/src/core/utils/async.ts +3 -0
- package/src/core/utils/constants.ts +32 -0
- package/src/core/utils/emitter/emitter.types.ts +23 -0
- package/src/core/utils/emitter/index.ts +68 -0
- package/src/core/utils/fingerprint.ts +20 -0
- package/src/core/utils/guards.ts +3 -0
- package/src/core/utils/id.ts +3 -0
- package/src/core/utils/index.ts +6 -0
- package/src/index.ts +3 -0
- package/src/react/index.ts +2 -0
- package/src/react/upload-provider.tsx +96 -0
- package/src/react/use-uploader.tsx +115 -0
- package/src/strategies/index.ts +15 -0
- package/src/strategies/multipart/index.ts +243 -0
- package/src/strategies/post/index.ts +93 -0
- 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
|
+
}
|