@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,435 @@
1
+ import type { CursorMap, IntentMap, UploadResultBase } from '../contracts'
2
+ import { computeFingerprint, fingerprintMatches } from '../utils/fingerprint'
3
+ import type { UploadCommand } from './commands.types'
4
+ import type { InternalEvent, UploadItem } from './internal-events.types'
5
+ import type { UploadProgress } from './progress.types'
6
+
7
+ export type UploadState<
8
+ M extends IntentMap,
9
+ C extends CursorMap<M>,
10
+ P extends string,
11
+ R extends UploadResultBase = UploadResultBase,
12
+ > = {
13
+ /** Map of uploads by localId for O(1) lookups */
14
+ items: Map<string, UploadItem<M, C, P, R>>
15
+ }
16
+
17
+ export function createReducer<
18
+ M extends IntentMap,
19
+ C extends CursorMap<M>,
20
+ P extends string,
21
+ R extends UploadResultBase = UploadResultBase,
22
+ >() {
23
+ return function reduce(
24
+ state: UploadState<M, C, P, R>,
25
+ event: UploadCommand<P> | InternalEvent<M, C, P, R>,
26
+ ): UploadState<M, C, P, R> {
27
+ const items = new Map(state.items)
28
+
29
+ const set = (localId: string, next: UploadItem<M, C, P, R>) => items.set(localId, next)
30
+
31
+ const now = Date.now()
32
+
33
+ // Type guard: Commands don't have dots in their type, internal events do (e.g., 'intent.ok')
34
+ const isCommand = (e: UploadCommand<P> | InternalEvent<M, C, P, R>): e is UploadCommand<P> => {
35
+ return 'type' in e && typeof e.type === 'string' && !e.type.includes('.')
36
+ }
37
+
38
+ // Command handling
39
+ if (isCommand(event)) {
40
+ switch (event.type) {
41
+ case 'addFiles': {
42
+ // handled in store (creates items + emits events)
43
+ break
44
+ }
45
+
46
+ case 'start': {
47
+ const item = items.get(event.localId)
48
+ if (!item) break
49
+ if (item.phase !== 'ready') break
50
+
51
+ set(event.localId, {
52
+ ...item,
53
+ phase: 'queued',
54
+ requestedAt: now,
55
+ })
56
+ break
57
+ }
58
+
59
+ case 'resume': {
60
+ const item = items.get(event.localId)
61
+ if (!item) break
62
+ if (item.phase !== 'paused') break
63
+ if (!item.file) break
64
+
65
+ set(event.localId, {
66
+ ...item,
67
+ file: item.file,
68
+ phase: 'queued',
69
+ requestedAt: now,
70
+ })
71
+ break
72
+ }
73
+
74
+ case 'pause': {
75
+ const item = items.get(event.localId)
76
+ if (!item) break
77
+
78
+ // If it's queued but not started, revert back to ready.
79
+ if (item.phase === 'queued') {
80
+ set(event.localId, {
81
+ ...item,
82
+ phase: 'ready',
83
+ })
84
+ }
85
+
86
+ // If it's uploading, store will abort and an internal 'paused' event will move it.
87
+ break
88
+ }
89
+
90
+ case 'cancel': {
91
+ const item = items.get(event.localId)
92
+ if (!item) break
93
+ if (item.phase === 'completed' || item.phase === 'canceled') break
94
+
95
+ set(event.localId, toCanceled(item, now))
96
+ break
97
+ }
98
+
99
+ case 'retry': {
100
+ const item = items.get(event.localId)
101
+ if (!item) break
102
+ if (item.phase !== 'error' || !item.retryable) break
103
+ if (!item.file) break
104
+
105
+ if (item.intent) {
106
+ // If we have progress and intent, we were in completing phase - retry completing
107
+ if (item.progress && item.progress.pct === 100) {
108
+ set(event.localId, {
109
+ ...item,
110
+ progress: item.progress,
111
+ intent: item.intent,
112
+ file: item.file,
113
+ phase: 'completing',
114
+ completingAt: now,
115
+ })
116
+ } else {
117
+ // Otherwise, retry from ready phase
118
+ set(event.localId, {
119
+ ...item,
120
+ file: item.file,
121
+ intent: item.intent,
122
+ phase: 'ready',
123
+ })
124
+ }
125
+ } else {
126
+ // For intent creation failures, increment attempt on retry
127
+ set(event.localId, {
128
+ ...item,
129
+ phase: 'creating_intent',
130
+ file: item.file,
131
+ attempt: (item.attempt ?? 1) + 1,
132
+ })
133
+ }
134
+ break
135
+ }
136
+
137
+ case 'rebind': {
138
+ const item = items.get(event.localId)
139
+ if (!item) break
140
+
141
+ if (item.phase === 'paused' && !item.file) {
142
+ const fp = computeFingerprint(event.file)
143
+ if (fingerprintMatches(fp, item.fingerprint)) {
144
+ set(event.localId, { ...item, file: event.file })
145
+ }
146
+ }
147
+ break
148
+ }
149
+
150
+ case 'remove': {
151
+ items.delete(event.localId)
152
+ break
153
+ }
154
+
155
+ case 'startAll':
156
+ case 'pauseAll':
157
+ case 'cancelAll': {
158
+ // handled in store
159
+ break
160
+ }
161
+ }
162
+
163
+ return { items }
164
+ }
165
+
166
+ // Internal event handling
167
+ switch (event.type) {
168
+ case 'files.added': {
169
+ const ev = event
170
+ for (const item of ev.items) {
171
+ if (!items.has(item.localId)) {
172
+ set(item.localId, {
173
+ phase: 'validating',
174
+ localId: item.localId,
175
+ purpose: item.purpose,
176
+ file: item.file,
177
+ fingerprint: item.fingerprint,
178
+ createdAt: item.createdAt,
179
+ })
180
+ }
181
+ }
182
+ break
183
+ }
184
+
185
+ case 'fingerprint.updated': {
186
+ const ev = event
187
+ const item = items.get(ev.localId)
188
+ if (!item) break
189
+
190
+ set(ev.localId, { ...item, fingerprint: ev.fingerprint })
191
+ break
192
+ }
193
+
194
+ case 'validation.ok': {
195
+ const item = items.get(event.localId)
196
+ if (!item || item.phase !== 'validating') break
197
+
198
+ set(event.localId, {
199
+ ...item,
200
+ phase: 'creating_intent',
201
+ attempt: 1,
202
+ })
203
+ break
204
+ }
205
+
206
+ case 'validation.failed': {
207
+ const ev = event
208
+ const item = items.get(ev.localId)
209
+ if (!item || item.phase !== 'validating') break
210
+
211
+ set(ev.localId, {
212
+ ...item,
213
+ phase: 'error',
214
+ error: { code: 'validation_failed', reason: ev.reason, message: String(ev.reason) },
215
+ retryable: false,
216
+ attempt: 1,
217
+ failedAt: now,
218
+ })
219
+ break
220
+ }
221
+
222
+ case 'intent.ok': {
223
+ const ev = event
224
+ const item = items.get(ev.localId)
225
+ if (!item || item.phase !== 'creating_intent') break
226
+
227
+ set(ev.localId, {
228
+ ...item,
229
+ phase: 'ready',
230
+ intent: ev.intent,
231
+ })
232
+ break
233
+ }
234
+
235
+ case 'intent.failed': {
236
+ const ev = event
237
+ const item = items.get(ev.localId)
238
+ if (!item || item.phase !== 'creating_intent') break
239
+
240
+ set(ev.localId, {
241
+ ...item,
242
+ phase: 'error',
243
+ error: ev.error,
244
+ retryable: ev.retryable,
245
+ failedAt: now,
246
+ })
247
+ break
248
+ }
249
+
250
+ case 'upload.begin': {
251
+ const ev = event
252
+ const item = items.get(ev.localId)
253
+ if (!item || item.phase !== 'queued') break
254
+
255
+ const total = item.file.size
256
+ const carried: UploadProgress | undefined = item.progress
257
+
258
+ const progress: UploadProgress = carried
259
+ ? { ...carried, totalBytes: total, pct: pct(carried.uploadedBytes, total) }
260
+ : { uploadedBytes: 0, totalBytes: total, pct: 0 }
261
+
262
+ set(ev.localId, {
263
+ ...item,
264
+ phase: 'uploading',
265
+ progress,
266
+ startedAt: ev.startedAt,
267
+ })
268
+ break
269
+ }
270
+
271
+ case 'upload.progress': {
272
+ const ev = event
273
+ const item = items.get(ev.localId)
274
+ if (!item || item.phase !== 'uploading') break
275
+
276
+ const progress: UploadProgress = {
277
+ uploadedBytes: ev.uploadedBytes,
278
+ totalBytes: ev.totalBytes,
279
+ pct: pct(ev.uploadedBytes, ev.totalBytes),
280
+ }
281
+
282
+ set(ev.localId, { ...item, progress })
283
+ break
284
+ }
285
+
286
+ case 'cursor.updated': {
287
+ const ev = event
288
+ const item = items.get(ev.localId)
289
+ if (!item) break
290
+
291
+ if (item.phase === 'uploading') {
292
+ set(ev.localId, { ...item, cursor: ev.cursor })
293
+ } else if (item.phase === 'queued') {
294
+ set(ev.localId, { ...item, cursor: ev.cursor })
295
+ } else if (item.phase === 'paused') {
296
+ set(ev.localId, { ...item, cursor: ev.cursor })
297
+ }
298
+ break
299
+ }
300
+
301
+ case 'upload.ok': {
302
+ const ev = event
303
+ const item = items.get(ev.localId)
304
+ if (!item || item.phase !== 'uploading') break
305
+
306
+ set(ev.localId, {
307
+ ...item,
308
+ phase: 'completing',
309
+ progress: { uploadedBytes: item.file.size, totalBytes: item.file.size, pct: 100 },
310
+ completingAt: now,
311
+ })
312
+ break
313
+ }
314
+
315
+ case 'upload.failed': {
316
+ const ev = event
317
+ const item = items.get(ev.localId)
318
+ if (!item || item.phase !== 'uploading') break
319
+
320
+ set(ev.localId, {
321
+ ...item,
322
+ phase: 'error',
323
+ error: ev.error,
324
+ retryable: ev.retryable,
325
+ attempt: 1,
326
+ failedAt: now,
327
+ })
328
+ break
329
+ }
330
+
331
+ case 'paused': {
332
+ const ev = event
333
+ const item = items.get(ev.localId)
334
+ if (!item || item.phase !== 'uploading') break
335
+
336
+ set(ev.localId, {
337
+ ...item,
338
+ phase: 'paused',
339
+ cursor: ev.cursor,
340
+ pausedAt: ev.pausedAt,
341
+ })
342
+ break
343
+ }
344
+
345
+ case 'canceled': {
346
+ const ev = event
347
+ const item = items.get(ev.localId)
348
+ if (!item) break
349
+
350
+ set(ev.localId, toCanceled(item, ev.canceledAt))
351
+ break
352
+ }
353
+
354
+ case 'complete.ok': {
355
+ const ev = event
356
+ const item = items.get(ev.localId)
357
+ if (!item || item.phase !== 'completing') break
358
+
359
+ set(ev.localId, {
360
+ ...item,
361
+ phase: 'completed',
362
+ completedBy: 'upload',
363
+ result: ev.result,
364
+ completedAt: now,
365
+ })
366
+ break
367
+ }
368
+
369
+ case 'dedupe.ok': {
370
+ const ev = event
371
+ const item = items.get(ev.localId)
372
+ if (!item || item.phase !== 'validating') break
373
+
374
+ set(ev.localId, {
375
+ phase: 'completed',
376
+ localId: item.localId,
377
+ file: item.file,
378
+ purpose: item.purpose,
379
+ fingerprint: item.fingerprint,
380
+ result: ev.result,
381
+ completedBy: 'dedupe',
382
+ completedAt: now,
383
+ createdAt: item.createdAt,
384
+ })
385
+ break
386
+ }
387
+
388
+ case 'complete.failed': {
389
+ const ev = event
390
+ const item = items.get(ev.localId)
391
+ if (!item || item.phase !== 'completing') break
392
+
393
+ // Increment attempt number for retry tracking
394
+ const nextAttempt = (item.attempt ?? 1) + 1
395
+
396
+ set(ev.localId, {
397
+ ...item,
398
+ phase: 'error',
399
+ error: ev.error,
400
+ retryable: ev.retryable,
401
+ attempt: nextAttempt,
402
+ failedAt: now,
403
+ })
404
+ break
405
+ }
406
+ }
407
+
408
+ return { items }
409
+ }
410
+ }
411
+
412
+ // Helpers
413
+ function toCanceled<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
414
+ item: UploadItem<M, C, P, R>,
415
+ canceledAt: number,
416
+ ): UploadItem<M, C, P, R> {
417
+ return {
418
+ phase: 'canceled',
419
+ localId: item.localId,
420
+ purpose: item.purpose,
421
+ fingerprint: item.fingerprint,
422
+ canceledAt,
423
+ createdAt: item.createdAt,
424
+ file: 'file' in item ? item.file : undefined,
425
+ intent: 'intent' in item ? item.intent : undefined,
426
+ cursor: 'cursor' in item ? item.cursor : undefined,
427
+ progress: 'progress' in item ? item.progress : undefined,
428
+ attempt: 'attempt' in item ? item.attempt : undefined,
429
+ }
430
+ }
431
+
432
+ function pct(uploaded: number, total: number) {
433
+ if (total <= 0) return 0
434
+ return Math.min(100, (uploaded / total) * 100)
435
+ }
@@ -0,0 +1,103 @@
1
+ import type { CursorMap, FileFingerprint, IntentMap, UploadResultBase } from '../../../contracts'
2
+ import { generateId } from '../../../utils/id'
3
+ import { validateFile, validateFileList } from '../../validation'
4
+ import { calculateFileChecksum, computeFingerprint } from '../store.libs'
5
+ import type { StoreRuntime } from '../store.types'
6
+
7
+ /**
8
+ * Handles the `addFiles` command.
9
+ *
10
+ * This function performs fast synchronous work immediately (validation + state insertion)
11
+ * and then schedules additional async work through the runtime effect queue
12
+ * (checksum computation, de-dupe checks, and final validation transitions).
13
+ *
14
+ * @template M - Intent map type
15
+ * @template C - Cursor map type
16
+ * @template P - Purpose string union type
17
+ *
18
+ * @param rt - Store runtime
19
+ * @param files - Files selected by the user
20
+ * @param purpose - Upload purpose used for validation/routing
21
+ */
22
+ export function handleAddFiles<
23
+ M extends IntentMap,
24
+ C extends CursorMap<M>,
25
+ P extends string,
26
+ R extends UploadResultBase,
27
+ >(rt: StoreRuntime<M, C, P, R>, files: File[], purpose: P) {
28
+ const existingCount = Array.from(rt.state.items.values()).filter(
29
+ (item) => item.purpose === purpose && item.phase !== 'canceled',
30
+ ).length
31
+
32
+ const { valid, rejected } = validateFileList(files, purpose, rt.opts.config, existingCount)
33
+
34
+ for (const { file, reason } of rejected) {
35
+ rt.emitter.emit('file.rejected', { reason, file })
36
+ }
37
+
38
+ const now = Date.now()
39
+ const toAdd: Array<{ localId: string; purpose: P; file: File; fingerprint: FileFingerprint; createdAt: number }> = []
40
+
41
+ for (const file of valid) {
42
+ const localId = generateId()
43
+ const fingerprint = (rt.opts.fingerprint ?? computeFingerprint)(file)
44
+
45
+ toAdd.push({ localId, purpose, file, fingerprint, createdAt: now })
46
+
47
+ rt.enqueueEffect(async () => {
48
+ // Calculate checksum for deduplication
49
+ let checksum: string | undefined
50
+ try {
51
+ checksum = await calculateFileChecksum(file)
52
+ // Update fingerprint with checksum
53
+ const updatedFingerprint = { ...fingerprint, checksum }
54
+ rt.applyInternal({ type: 'fingerprint.updated', localId, fingerprint: updatedFingerprint })
55
+ } catch (err) {
56
+ // If checksum calculation fails, continue without checksum
57
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
58
+ console.warn('[UploadEngine] Failed to calculate checksum:', err)
59
+ }
60
+ }
61
+
62
+ // Check for existing file by checksum if available
63
+ if (checksum && rt.opts.api.findByChecksum) {
64
+ try {
65
+ const existingFile = await rt.opts.api.findByChecksum({ checksum, purpose })
66
+ if (existingFile) {
67
+ // File already exists, skip upload and mark as completed
68
+ const currentItem = rt.state.items.get(localId)
69
+ if (currentItem && currentItem.phase === 'validating') {
70
+ rt.applyInternal({ type: 'dedupe.ok', localId, result: existingFile })
71
+ return
72
+ }
73
+ }
74
+ } catch (err) {
75
+ // If findByChecksum fails, continue with normal upload flow
76
+ if (typeof window !== 'undefined' && process.env.NODE_ENV === 'development') {
77
+ console.warn('[UploadEngine] Failed to check for existing file:', err)
78
+ }
79
+ }
80
+ }
81
+
82
+ // Allow extra sync validation hook
83
+ const reason = rt.opts.validateFile?.(file, purpose)
84
+ if (reason) {
85
+ rt.applyInternal({ type: 'validation.failed', localId, reason })
86
+ return
87
+ }
88
+
89
+ // Still run config validation per-file (covers case where caller skipped validateFileList)
90
+ const cfgReason = validateFile(file, purpose, rt.opts.config)
91
+ if (cfgReason) {
92
+ rt.applyInternal({ type: 'validation.failed', localId, reason: cfgReason })
93
+ return
94
+ }
95
+
96
+ rt.applyInternal({ type: 'validation.ok', localId })
97
+ })
98
+ }
99
+
100
+ if (toAdd.length > 0) {
101
+ rt.applyInternal({ type: 'files.added', items: toAdd })
102
+ }
103
+ }
@@ -0,0 +1,56 @@
1
+ import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
2
+ import { hasIntent, isMultipartIntent } from '../store.libs'
3
+ import type { StoreRuntime } from '../store.types'
4
+
5
+ /**
6
+ * Cancels a single upload item and aborts related inflight operations.
7
+ *
8
+ * This function is intentionally best-effort: abort calls can throw or be unsupported,
9
+ * and server-side multipart abort is attempted only when the adapter provides it.
10
+ */
11
+ export function handleCancel<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase>(
12
+ rt: StoreRuntime<M, C, P, R>,
13
+ localId: string,
14
+ ) {
15
+ const item = rt.state.items.get(localId)
16
+
17
+ // Abort inflight intent
18
+ const intentCtl = rt.inflightIntents.get(localId)
19
+ if (intentCtl) {
20
+ intentCtl.abort({ reason: 'cancel' })
21
+ rt.inflightIntents.delete(localId)
22
+ }
23
+
24
+ // Abort inflight complete
25
+ const completeCtl = rt.inflightCompletes.get(localId)
26
+ if (completeCtl) {
27
+ completeCtl.abort({ reason: 'cancel' })
28
+ rt.inflightCompletes.delete(localId)
29
+ }
30
+
31
+ // Abort inflight upload
32
+ const inflight = rt.inflightUploads.get(localId)
33
+ if (inflight) {
34
+ inflight.mode = 'cancel'
35
+ inflight.controller.abort({ reason: 'cancel' })
36
+ }
37
+
38
+ // Best-effort server abort for multipart uploads
39
+ if (item && hasIntent(item) && item.intent.strategy === 'multipart' && rt.opts.api.multipart?.abort) {
40
+ const intent = item.intent
41
+ if (isMultipartIntent(intent)) {
42
+ rt.enqueueEffect(async () => {
43
+ try {
44
+ const abort = rt.opts.api.multipart?.abort
45
+ if (abort) {
46
+ await abort({ fileId: intent.fileId, uploadId: intent.uploadId })
47
+ }
48
+ } catch {
49
+ // ignore
50
+ }
51
+ })
52
+ }
53
+ }
54
+
55
+ // If not inflight, reducer will mark canceled.
56
+ }
@@ -0,0 +1,66 @@
1
+ import type { CursorMap, IntentMap, UploadResultBase } from '../../../contracts'
2
+ import type { UploadState } from '../../reducer'
3
+ import type { StoreOptions } from '../store.types'
4
+
5
+ export function cleanupOldItems<
6
+ M extends IntentMap,
7
+ C extends CursorMap<M>,
8
+ P extends string,
9
+ R extends UploadResultBase,
10
+ >(opts: StoreOptions<M, C, P, R>, state: UploadState<M, C, P, R>): UploadState<M, C, P, R> | null {
11
+ const maxItems = opts.config.maxItems
12
+ const completedTTL = opts.config.completedItemTTL
13
+ const now = Date.now()
14
+
15
+ // Collect items to remove
16
+ const toRemove: string[] = []
17
+ const items = Array.from(state.items.entries())
18
+
19
+ // Remove items that exceed TTL
20
+ if (completedTTL !== undefined) {
21
+ for (const [localId, item] of items) {
22
+ if (
23
+ (item.phase === 'completed' || item.phase === 'canceled') &&
24
+ 'completedAt' in item &&
25
+ typeof item.completedAt === 'number' &&
26
+ now - item.completedAt > completedTTL
27
+ ) {
28
+ toRemove.push(localId)
29
+ } else if (
30
+ item.phase === 'canceled' &&
31
+ 'canceledAt' in item &&
32
+ typeof item.canceledAt === 'number' &&
33
+ now - item.canceledAt > completedTTL
34
+ ) {
35
+ toRemove.push(localId)
36
+ }
37
+ }
38
+ }
39
+
40
+ // If still over limit, remove oldest completed/canceled items
41
+ const remainingItems = items.filter(([id]) => !toRemove.includes(id))
42
+ if (remainingItems.length > maxItems) {
43
+ const completedOrCanceled = remainingItems
44
+ .filter(([, item]) => item.phase === 'completed' || item.phase === 'canceled')
45
+ .sort(([, a], [, b]) => {
46
+ const aTime = 'completedAt' in a ? a.completedAt : 'canceledAt' in a ? a.canceledAt : 0
47
+ const bTime = 'completedAt' in b ? b.completedAt : 'canceledAt' in b ? b.canceledAt : 0
48
+ return aTime - bTime
49
+ })
50
+
51
+ const excess = remainingItems.length - maxItems
52
+ for (let i = 0; i < excess && i < completedOrCanceled.length; i++) {
53
+ toRemove.push(completedOrCanceled[i][0])
54
+ }
55
+ }
56
+
57
+ // Remove items
58
+ if (toRemove.length === 0) return null
59
+
60
+ const nextItems = new Map(state.items)
61
+ for (const localId of toRemove) {
62
+ nextItems.delete(localId)
63
+ }
64
+
65
+ return { ...state, items: nextItems }
66
+ }