@gentleduck/upload 0.2.5 → 0.2.6
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/README.md +320 -0
- package/dist/core/index.d.ts +1 -1
- package/dist/core/index.js +1 -1
- package/dist/{core-BNxznRif.js → core-CMkrP9DX.js} +2 -2
- package/dist/{index-BXg9WIce.d.ts → index-C38Jmizu.d.ts} +51 -33
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/react/index.d.ts +8 -47
- package/dist/react/index.js +1 -1
- package/dist/strategies/index.d.ts +5 -5
- package/package.json +1 -1
- package/src/__test__/effect-pool.test.ts +5 -0
- package/src/__test__/emitter.test.ts +85 -0
- package/src/__test__/fingerprint.test.ts +49 -0
- package/src/__test__/guards.test.ts +30 -0
- package/src/__test__/id.test.ts +14 -0
- package/src/__test__/persistence.test.ts +156 -0
- package/src/__test__/store-integration.test.ts +5 -0
- package/src/__test__/store-libs.test.ts +138 -0
- package/src/__test__/transport-libs.test.ts +42 -0
- package/src/__test__/transport.test.ts +182 -0
- package/src/core/client/client.ts +50 -0
- package/src/core/client/client.types.ts +184 -0
- package/src/core/contracts/contracts.types.ts +179 -0
- package/src/core/contracts/transport/transport.libs.ts +7 -32
- package/src/core/contracts/transport/transport.ts +5 -7
- package/src/core/engine/engine.types.ts +364 -0
- package/src/core/engine/internal-events.types.ts +3 -23
- package/src/core/engine/reducer.ts +5 -11
- package/src/core/engine/store/handlers/add-file.ts +6 -21
- package/src/core/engine/store/handlers/run-upload.ts +7 -7
- package/src/core/engine/store/handlers.ts +819 -0
- package/src/core/engine/store/store.libs.ts +17 -57
- package/src/core/engine/store/store.runtime.ts +10 -20
- package/src/core/engine/validation/file.ts +1 -1
- package/src/core/engine/validation.ts +216 -0
- package/src/core/utils/emitter/emitter.ts +143 -0
- package/src/react/upload-provider.tsx +7 -58
- package/src/react/uploader.types.ts +83 -0
- package/src/react/use-uploader.tsx +2 -5
- package/src/strategies/multipart/index.ts +9 -12
- package/src/strategies/post/index.ts +6 -46
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{C as e,S as t,_ as n,a as r,b as i,c as a,d as o,f as s,g as c,h as l,i as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,u as y,v as b,w as x,x as S,y as C}from"./core-
|
|
1
|
+
import{C as e,S as t,_ as n,a as r,b as i,c as a,d as o,f as s,g as c,h as l,i as u,l as d,m as f,n as p,o as m,p as h,r as g,s as _,t as v,u as y,v as b,w as x,x as S,y as C}from"./core-CMkrP9DX.js";import{t as w}from"./async-DxRWu4le.js";import{UploadProvider as T,createUploadFactory as E,useOptionalUploadStore as D,useUploadStore as O,useUploader as k,useUploaderActions as A}from"./react/index.js";import{PostStrategy as j,createStrategyRegistry as M,multipartStrategy as N}from"./strategies/index.js";export{C as DEFAULT_MAX_ATTEMPTS,i as DEFAULT_MAX_CONCURRENT_UPLOADS,S as DEFAULT_MAX_ITEMS,t as DEFAULT_PROGRESS_THROTTLE_MS,e as DEFAULT_RETRY_DELAY_BASE_MS,x as DEFAULT_RETRY_DELAY_MAX_MS,d as IndexedDBAdapter,a as LocalStorageAdapter,_ as MemoryAdapter,j as PostStrategy,T as UploadProvider,o as computeFingerprint,g as createReducer,M as createStrategyRegistry,u as createTypedEmitter,v as createUploadClient,E as createUploadFactory,p as createUploadStore,y as createXHRTransport,r as deserializeSnapshot,s as fingerprintMatches,n as generateId,c as isRecord,N as multipartStrategy,b as resolveUploadConfig,m as serializeSnapshot,w as sleep,D as useOptionalUploadStore,O as useUploadStore,k as useUploader,A as useUploaderActions,h as validateFile,f as validateFileList,l as validateIntent};
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,28 +1,9 @@
|
|
|
1
1
|
import { f as IntentMap, l as UploadResultBase, o as CursorMap } from "../strategy.types-gg9ZStRe.js";
|
|
2
|
-
import { B as UploadItem, E as UploadEventMap, G as UploadCommand, T as UploadStore, V as UploadPhase } from "../index-
|
|
2
|
+
import { B as UploadItem, E as UploadEventMap, G as UploadCommand, T as UploadStore, V as UploadPhase } from "../index-C38Jmizu.js";
|
|
3
3
|
import { ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
//#region src/react/upload-provider.d.ts
|
|
6
|
-
/**
|
|
7
|
-
* Provider component that makes the upload store available to child components.
|
|
8
|
-
*
|
|
9
|
-
* @template M - Intent map type
|
|
10
|
-
* @template C - Cursor map type
|
|
11
|
-
* @template P - Purpose type
|
|
12
|
-
*
|
|
13
|
-
* @param props - Provider props
|
|
14
|
-
* @param props.store - Upload store instance to provide
|
|
15
|
-
* @param props.children - Child components that can access the store
|
|
16
|
-
*
|
|
17
|
-
* @returns {JSX.Element} Context provider wrapping children
|
|
18
|
-
*
|
|
19
|
-
* @example
|
|
20
|
-
* ```tsx
|
|
21
|
-
* <UploadProvider store={store}>
|
|
22
|
-
* <UploadDropzone purpose="avatar" />
|
|
23
|
-
* </UploadProvider>
|
|
24
|
-
* ```
|
|
25
|
-
*/
|
|
6
|
+
/** Provides an upload store to descendants. */
|
|
26
7
|
declare function UploadProvider<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>({
|
|
27
8
|
store,
|
|
28
9
|
children
|
|
@@ -31,29 +12,12 @@ declare function UploadProvider<M extends IntentMap, C extends CursorMap<M>, P e
|
|
|
31
12
|
children: ReactNode;
|
|
32
13
|
}): React.JSX.Element;
|
|
33
14
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* They use this helper so the context read stays unconditional without throwing
|
|
38
|
-
* when a caller intentionally passes a store instance.
|
|
15
|
+
* Read the upload store from context if provided. Returns `null` outside a
|
|
16
|
+
* provider so higher-level hooks can keep their context read unconditional
|
|
17
|
+
* while still accepting an explicit store argument.
|
|
39
18
|
*/
|
|
40
19
|
declare function useOptionalUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>(): UploadStore<M, C, P, R> | null;
|
|
41
|
-
/**
|
|
42
|
-
* Hook to access the upload store from React context.
|
|
43
|
-
*
|
|
44
|
-
* @template M - Intent map type
|
|
45
|
-
* @template C - Cursor map type
|
|
46
|
-
* @template P - Purpose type
|
|
47
|
-
*
|
|
48
|
-
* @returns {UploadStore<M, C, P>} The upload store instance
|
|
49
|
-
* @throws {Error} If called outside of UploadProvider
|
|
50
|
-
*
|
|
51
|
-
* @example
|
|
52
|
-
* ```tsx
|
|
53
|
-
* const store = useUploadStore()
|
|
54
|
-
* const snapshot = store.getSnapshot()
|
|
55
|
-
* ```
|
|
56
|
-
*/
|
|
20
|
+
/** Read the upload store from context. Throws when called outside `UploadProvider`. */
|
|
57
21
|
declare function useUploadStore<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase>(): UploadStore<M, C, P, R>;
|
|
58
22
|
//#endregion
|
|
59
23
|
//#region src/react/use-uploader.d.ts
|
|
@@ -74,11 +38,8 @@ type Uploader<M extends IntentMap, C extends CursorMap<M>, P extends string, R e
|
|
|
74
38
|
ready: UploadItem<M, C, P, R>[];
|
|
75
39
|
};
|
|
76
40
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* This is declared explicitly instead of relying on an inferred object return
|
|
80
|
-
* type so the generated package declarations do not leak the internal generic
|
|
81
|
-
* names created by `store.on.bind(store)`.
|
|
41
|
+
* Imperative action surface. Declared explicitly (not inferred) so generated
|
|
42
|
+
* .d.ts files do not leak internal generic names created by `store.on.bind(store)`.
|
|
82
43
|
*/
|
|
83
44
|
type UploaderActions<M extends IntentMap, C extends CursorMap<M>, P extends string, R extends UploadResultBase = UploadResultBase> = Pick<UploadStore<M, C, P, R>, 'dispatch' | 'on'> & {
|
|
84
45
|
store: UploadStore<M, C, P, R>;
|
package/dist/react/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import{g as e}from"../core-CMkrP9DX.js";import{jsxDEV as t}from"react/jsx-dev-runtime";import*as n from"react";import{createContext as r,useContext as i}from"react";const a=r(null);function o({store:e,children:n}){return t(a.Provider,{value:e,children:n},void 0,!1,{fileName:`/home/runner/work/duck-upload/duck-upload/packages/duck-upload/src/react/upload-provider.tsx`,lineNumber:18,columnNumber:10},this)}function s(){let e=i(a);if(e===null)return null;if(!l(e))throw Error(`UploadProvider received an invalid store value`);return e}function c(){let e=s();if(!e)throw Error(`useUploadStore must be used within UploadProvider`);return e}function l(t){return e(t)?typeof t.getSnapshot==`function`&&typeof t.subscribe==`function`&&typeof t.dispatch==`function`&&typeof t.on==`function`&&typeof t.off==`function`&&typeof t.waitFor==`function`:!1}function u(e){return()=>d(e)}function d(e){let t=s(),r=e??t;if(!r)throw Error(`useUploader must be used within UploadProvider when no store argument is provided`);let i=n.useSyncExternalStore(r.subscribe.bind(r),r.getSnapshot.bind(r),r.getSnapshot.bind(r)),a=n.useMemo(()=>Array.from(i.items.values()),[i.items]),o=n.useMemo(()=>{let e={};return a.forEach(t=>{e[t.phase]||(e[t.phase]=[]),e[t.phase].push(t)}),e},[a]);return{items:a,byPhase:o,dispatch:r.dispatch.bind(r),on:r.on.bind(r),off:r.off.bind(r),uploading:o.uploading||[],paused:o.paused||[],completed:o.completed||[],failed:o.error||[],ready:o.ready||[]}}function f(){let e=c();return{dispatch:n.useMemo(()=>e.dispatch.bind(e),[e]),on:n.useMemo(()=>e.on.bind(e),[e]),store:e}}export{o as UploadProvider,u as createUploadFactory,s as useOptionalUploadStore,c as useUploadStore,d as useUploader,f as useUploaderActions};
|
|
@@ -35,14 +35,14 @@ declare function multipartStrategy<M extends IntentMap & {
|
|
|
35
35
|
//#endregion
|
|
36
36
|
//#region src/strategies/post/index.d.ts
|
|
37
37
|
type PostIntent = {
|
|
38
|
-
|
|
39
|
-
fileId: string; /** Presigned POST URL (form action) */
|
|
40
|
-
url: string; /** Form fields to include before the file */
|
|
41
|
-
fields: Record<string, string>; /** Optional
|
|
38
|
+
strategy: 'post';
|
|
39
|
+
fileId: string; /** Presigned POST URL (form action). */
|
|
40
|
+
url: string; /** Form fields to include before the file. */
|
|
41
|
+
fields: Record<string, string>; /** Optional expiry of the presigned URL. */
|
|
42
42
|
expiresAt?: string;
|
|
43
43
|
};
|
|
44
44
|
type PostCursor = {
|
|
45
|
-
/** Bytes uploaded
|
|
45
|
+
/** Bytes uploaded — progress display only; not used for resume. */uploadedBytes: number;
|
|
46
46
|
};
|
|
47
47
|
declare function PostStrategy<M extends IntentMap & {
|
|
48
48
|
post: PostIntent;
|
package/package.json
CHANGED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Tests in this file referenced a planned effect-pool / watchdog / regression
|
|
2
|
+
// API that was never ported into `src/`. The intended surface (WaitForError,
|
|
3
|
+
// runUpload handler, frozen snapshots, listener-error hooks, queue compaction,
|
|
4
|
+
// rebind handler, etc.) does not exist. Deleted until the source-of-truth API
|
|
5
|
+
// stabilizes.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
2
|
+
import { createTypedEmitter } from '../core/utils/emitter'
|
|
3
|
+
|
|
4
|
+
type Events = { foo: { x: number }; bar: { y: string } }
|
|
5
|
+
|
|
6
|
+
describe('createTypedEmitter', () => {
|
|
7
|
+
test('emits to subscribers', () => {
|
|
8
|
+
const e = createTypedEmitter<Events>()
|
|
9
|
+
const fn = vi.fn()
|
|
10
|
+
e.on('foo', fn)
|
|
11
|
+
e.emit('foo', { x: 1 })
|
|
12
|
+
e.emit('foo', { x: 2 })
|
|
13
|
+
expect(fn).toHaveBeenCalledTimes(2)
|
|
14
|
+
expect(fn).toHaveBeenNthCalledWith(1, { x: 1 })
|
|
15
|
+
expect(fn).toHaveBeenNthCalledWith(2, { x: 2 })
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('does not invoke unrelated handlers', () => {
|
|
19
|
+
const e = createTypedEmitter<Events>()
|
|
20
|
+
const fooFn = vi.fn()
|
|
21
|
+
const barFn = vi.fn()
|
|
22
|
+
e.on('foo', fooFn)
|
|
23
|
+
e.on('bar', barFn)
|
|
24
|
+
e.emit('bar', { y: 'z' })
|
|
25
|
+
expect(fooFn).not.toHaveBeenCalled()
|
|
26
|
+
expect(barFn).toHaveBeenCalledOnce()
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('on returns unsubscriber', () => {
|
|
30
|
+
const e = createTypedEmitter<Events>()
|
|
31
|
+
const fn = vi.fn()
|
|
32
|
+
const off = e.on('foo', fn)
|
|
33
|
+
off()
|
|
34
|
+
e.emit('foo', { x: 1 })
|
|
35
|
+
expect(fn).not.toHaveBeenCalled()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('off detaches listener', () => {
|
|
39
|
+
const e = createTypedEmitter<Events>()
|
|
40
|
+
const fn = vi.fn()
|
|
41
|
+
e.on('foo', fn)
|
|
42
|
+
e.off('foo', fn)
|
|
43
|
+
e.emit('foo', { x: 1 })
|
|
44
|
+
expect(fn).not.toHaveBeenCalled()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('isolates throwing listeners', () => {
|
|
48
|
+
const e = createTypedEmitter<Events>()
|
|
49
|
+
const ok = vi.fn()
|
|
50
|
+
e.on('foo', () => {
|
|
51
|
+
throw new Error('boom')
|
|
52
|
+
})
|
|
53
|
+
e.on('foo', ok)
|
|
54
|
+
expect(() => e.emit('foo', { x: 1 })).not.toThrow()
|
|
55
|
+
expect(ok).toHaveBeenCalledOnce()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('subsequent on() adds the listener for future emits', () => {
|
|
59
|
+
const e = createTypedEmitter<Events>()
|
|
60
|
+
const a: number[] = []
|
|
61
|
+
const b: number[] = []
|
|
62
|
+
e.on('foo', (p) => a.push(p.x))
|
|
63
|
+
e.on('foo', (p) => b.push(p.x))
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < 5; i++) e.emit('foo', { x: i })
|
|
66
|
+
expect(a).toEqual([0, 1, 2, 3, 4])
|
|
67
|
+
expect(b).toEqual([0, 1, 2, 3, 4])
|
|
68
|
+
|
|
69
|
+
const c: number[] = []
|
|
70
|
+
e.on('foo', (p) => c.push(p.x))
|
|
71
|
+
e.emit('foo', { x: 99 })
|
|
72
|
+
expect(c).toEqual([99])
|
|
73
|
+
expect(a).toEqual([0, 1, 2, 3, 4, 99])
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('off() with a non-subscribed callback is a no-op', () => {
|
|
77
|
+
const e = createTypedEmitter<Events>()
|
|
78
|
+
const tracker: number[] = []
|
|
79
|
+
e.on('foo', (p) => tracker.push(p.x))
|
|
80
|
+
const unrelated = (p: { x: number }) => tracker.push(-p.x)
|
|
81
|
+
expect(() => e.off('foo', unrelated)).not.toThrow()
|
|
82
|
+
e.emit('foo', { x: 7 })
|
|
83
|
+
expect(tracker).toEqual([7])
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { computeFingerprint, fingerprintMatches } from '../core/utils/fingerprint'
|
|
3
|
+
|
|
4
|
+
function fileOf(name: string, content: string, type = 'text/plain', lastModified = 0): File {
|
|
5
|
+
return new File([content], name, { type, lastModified })
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
describe('computeFingerprint', () => {
|
|
9
|
+
test('captures name, size, type, lastModified', () => {
|
|
10
|
+
const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
|
|
11
|
+
expect(computeFingerprint(f)).toEqual({
|
|
12
|
+
name: 'a.txt',
|
|
13
|
+
size: 5,
|
|
14
|
+
type: 'text/plain',
|
|
15
|
+
lastModified: 1234,
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('fingerprintMatches', () => {
|
|
21
|
+
test('same identity matches', () => {
|
|
22
|
+
const f = fileOf('a.txt', 'hello', 'text/plain', 1234)
|
|
23
|
+
expect(fingerprintMatches(computeFingerprint(f), computeFingerprint(f))).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('different name does not match', () => {
|
|
27
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello'))
|
|
28
|
+
const b = computeFingerprint(fileOf('b.txt', 'hello'))
|
|
29
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('different size does not match', () => {
|
|
33
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello'))
|
|
34
|
+
const b = computeFingerprint(fileOf('a.txt', 'hello!'))
|
|
35
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('different lastModified does not match', () => {
|
|
39
|
+
const a = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 1))
|
|
40
|
+
const b = computeFingerprint(fileOf('a.txt', 'hello', 'text/plain', 2))
|
|
41
|
+
expect(fingerprintMatches(a, b)).toBe(false)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('type difference is intentionally ignored', () => {
|
|
45
|
+
const a = computeFingerprint(fileOf('a', 'hello', 'text/plain', 1))
|
|
46
|
+
const b = computeFingerprint(fileOf('a', 'hello', 'application/octet-stream', 1))
|
|
47
|
+
expect(fingerprintMatches(a, b)).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { isRecord } from '../core/utils/guards'
|
|
3
|
+
|
|
4
|
+
describe('isRecord', () => {
|
|
5
|
+
test('plain object', () => {
|
|
6
|
+
expect(isRecord({})).toBe(true)
|
|
7
|
+
expect(isRecord({ a: 1 })).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('null', () => {
|
|
11
|
+
expect(isRecord(null)).toBe(false)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('undefined', () => {
|
|
15
|
+
expect(isRecord(undefined)).toBe(false)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('primitives', () => {
|
|
19
|
+
expect(isRecord(0)).toBe(false)
|
|
20
|
+
expect(isRecord('a')).toBe(false)
|
|
21
|
+
expect(isRecord(true)).toBe(false)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// NOTE: the current implementation treats arrays as records (typeof === 'object').
|
|
25
|
+
// Documented limitation: isRecord does not narrow out arrays.
|
|
26
|
+
test('arrays are treated as records by the current implementation', () => {
|
|
27
|
+
expect(isRecord([])).toBe(true)
|
|
28
|
+
expect(isRecord([1, 2, 3])).toBe(true)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { generateId } from '../core/utils/id'
|
|
3
|
+
|
|
4
|
+
describe('generateId', () => {
|
|
5
|
+
test('starts with the `upload_` prefix', () => {
|
|
6
|
+
expect(generateId()).toMatch(/^upload_/)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
test('produces unique values across many calls', () => {
|
|
10
|
+
const set = new Set<string>()
|
|
11
|
+
for (let i = 0; i < 10_000; i++) set.add(generateId())
|
|
12
|
+
expect(set.size).toBe(10_000)
|
|
13
|
+
})
|
|
14
|
+
})
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import type { UploadState } from '../core/engine/reducer'
|
|
3
|
+
import { MemoryAdapter } from '../core/persistence'
|
|
4
|
+
import { deserializeSnapshot, serializeSnapshot } from '../core/persistence/persistence'
|
|
5
|
+
import type { PersistedSnapshot } from '../core/persistence/persistence.types'
|
|
6
|
+
|
|
7
|
+
type Intents = { post: { strategy: 'post'; fileId: string; url: string } }
|
|
8
|
+
type Cursors = { post: { offset: number } }
|
|
9
|
+
type Purpose = 'avatar'
|
|
10
|
+
type Result = { fileId: string; key: string }
|
|
11
|
+
|
|
12
|
+
const isPurpose = (v: string): v is Purpose => v === 'avatar'
|
|
13
|
+
const isIntent = (v: unknown): v is Intents[keyof Intents] => {
|
|
14
|
+
return (
|
|
15
|
+
typeof v === 'object' &&
|
|
16
|
+
v !== null &&
|
|
17
|
+
(v as { strategy?: unknown }).strategy === 'post' &&
|
|
18
|
+
typeof (v as { fileId?: unknown }).fileId === 'string'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
const hasStrategy = (s: string) => s === 'post'
|
|
22
|
+
|
|
23
|
+
describe('serializeSnapshot', () => {
|
|
24
|
+
test('skips items without intents and terminal states', () => {
|
|
25
|
+
const state: UploadState<Intents, Cursors, Purpose, Result> = {
|
|
26
|
+
items: new Map([
|
|
27
|
+
[
|
|
28
|
+
'a',
|
|
29
|
+
{
|
|
30
|
+
phase: 'validating',
|
|
31
|
+
localId: 'a',
|
|
32
|
+
purpose: 'avatar',
|
|
33
|
+
fingerprint: { name: 'a', size: 1, type: 't', lastModified: 0 },
|
|
34
|
+
file: new File(['a'], 'a'),
|
|
35
|
+
createdAt: 0,
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
[
|
|
39
|
+
'b',
|
|
40
|
+
{
|
|
41
|
+
phase: 'completed',
|
|
42
|
+
localId: 'b',
|
|
43
|
+
purpose: 'avatar',
|
|
44
|
+
fingerprint: { name: 'b', size: 1, type: 't', lastModified: 0 },
|
|
45
|
+
file: new File(['b'], 'b'),
|
|
46
|
+
intent: { strategy: 'post', fileId: 'b', url: 'x' },
|
|
47
|
+
completedBy: 'upload',
|
|
48
|
+
result: { fileId: 'b', key: 'k/b' },
|
|
49
|
+
completedAt: 0,
|
|
50
|
+
createdAt: 0,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
[
|
|
54
|
+
'c',
|
|
55
|
+
{
|
|
56
|
+
phase: 'uploading',
|
|
57
|
+
localId: 'c',
|
|
58
|
+
purpose: 'avatar',
|
|
59
|
+
fingerprint: { name: 'c', size: 10, type: 't', lastModified: 0 },
|
|
60
|
+
file: new File(['c'], 'c'),
|
|
61
|
+
intent: { strategy: 'post', fileId: 'c', url: 'x' },
|
|
62
|
+
startedAt: 0,
|
|
63
|
+
progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
|
|
64
|
+
createdAt: 0,
|
|
65
|
+
cursor: { strategy: 'post', value: { offset: 5 } },
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
]),
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const snap = serializeSnapshot(state, 1)
|
|
72
|
+
expect(Object.keys(snap.items)).toEqual(['c'])
|
|
73
|
+
expect(snap.version).toBe(1)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('deserializeSnapshot', () => {
|
|
78
|
+
test('restores resumable items into the paused phase', () => {
|
|
79
|
+
const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
|
|
80
|
+
version: 1,
|
|
81
|
+
createdAt: 100,
|
|
82
|
+
items: {
|
|
83
|
+
c: {
|
|
84
|
+
id: 'c',
|
|
85
|
+
purpose: 'avatar',
|
|
86
|
+
status: 'uploading',
|
|
87
|
+
file: { name: 'c', size: 10, type: 't', lastModified: 0 },
|
|
88
|
+
intent: { strategy: 'post', fileId: 'c', url: 'x' },
|
|
89
|
+
cursor: { strategy: 'post', value: { offset: 5 } },
|
|
90
|
+
progress: { uploadedBytes: 5, totalBytes: 10, pct: 50 },
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
|
|
96
|
+
isPurpose,
|
|
97
|
+
isIntent,
|
|
98
|
+
hasStrategy,
|
|
99
|
+
})
|
|
100
|
+
expect(out).not.toBeNull()
|
|
101
|
+
const item = out?.items.get('c')
|
|
102
|
+
expect(item?.phase).toBe('paused')
|
|
103
|
+
if (item?.phase === 'paused') {
|
|
104
|
+
expect(item.cursor).toEqual({ strategy: 'post', value: { offset: 5 } })
|
|
105
|
+
expect(item.progress.pct).toBe(50)
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('drops items missing a cursor', () => {
|
|
110
|
+
const snap: PersistedSnapshot<Intents, Cursors, Purpose> = {
|
|
111
|
+
version: 1,
|
|
112
|
+
createdAt: 100,
|
|
113
|
+
items: {
|
|
114
|
+
c: {
|
|
115
|
+
id: 'c',
|
|
116
|
+
purpose: 'avatar',
|
|
117
|
+
status: 'uploading',
|
|
118
|
+
file: { name: 'c', size: 10, type: 't', lastModified: 0 },
|
|
119
|
+
intent: { strategy: 'post', fileId: 'c', url: 'x' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
|
|
124
|
+
isPurpose,
|
|
125
|
+
isIntent,
|
|
126
|
+
hasStrategy,
|
|
127
|
+
})
|
|
128
|
+
expect(out?.items.size).toBe(0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('returns null without guards', () => {
|
|
132
|
+
const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
|
|
133
|
+
const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, { hasStrategy })
|
|
134
|
+
expect(out).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('accepts snapshots and ignores unrecognized version when not enforced', () => {
|
|
138
|
+
const snap: PersistedSnapshot<Intents, Cursors, Purpose> = { version: 1, createdAt: 0, items: {} }
|
|
139
|
+
const out = deserializeSnapshot<Intents, Cursors, Purpose, Result>(snap, {
|
|
140
|
+
isPurpose,
|
|
141
|
+
isIntent,
|
|
142
|
+
hasStrategy,
|
|
143
|
+
})
|
|
144
|
+
expect(out).not.toBeNull()
|
|
145
|
+
expect(out?.items.size).toBe(0)
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
describe('MemoryAdapter', () => {
|
|
150
|
+
test('round-trips a value', async () => {
|
|
151
|
+
await MemoryAdapter.save('k', { a: 1 })
|
|
152
|
+
expect(await MemoryAdapter.load('k')).toEqual({ a: 1 })
|
|
153
|
+
await MemoryAdapter.clear('k')
|
|
154
|
+
expect(await MemoryAdapter.load('k')).toBeNull()
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Tests in this file referenced a planned store API surface (store.flush,
|
|
2
|
+
// store.destroy, store.dispose, store.has, store.purge, store.outcomes,
|
|
3
|
+
// store.cleanup, store.isDestroyed, store.ready, rebind handler, tombstones,
|
|
4
|
+
// onPersistenceLoopStarved, etc.) that does not exist in `src/`. Deleted
|
|
5
|
+
// until the source-of-truth API stabilizes.
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { resolveUploadConfig } from '../core/client'
|
|
3
|
+
import {
|
|
4
|
+
calculateFileChecksum,
|
|
5
|
+
isAbortError,
|
|
6
|
+
isMultipartIntent,
|
|
7
|
+
normalizeError,
|
|
8
|
+
retryDecision,
|
|
9
|
+
} from '../core/engine/store/store.libs'
|
|
10
|
+
|
|
11
|
+
describe('normalizeError', () => {
|
|
12
|
+
test('honors a custom normalizer', () => {
|
|
13
|
+
const err = normalizeError('anything', () => ({ code: 'auth', message: 'no', retryable: false }))
|
|
14
|
+
expect(err.code).toBe('auth')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('classifies abort-shaped errors as aborted/non-retryable', () => {
|
|
18
|
+
const err = normalizeError({ code: 'aborted', reason: 'cancel' })
|
|
19
|
+
expect(err.code).toBe('aborted')
|
|
20
|
+
expect(err.retryable).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('thrown value with statusCode alias classifies as http', () => {
|
|
24
|
+
const err = normalizeError({ statusCode: 503, message: 'down' })
|
|
25
|
+
expect(err.code).toBe('http')
|
|
26
|
+
expect((err as { status?: number }).status).toBe(503)
|
|
27
|
+
expect(err.retryable).toBe(true)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('http 5xx is retryable', () => {
|
|
31
|
+
const err = normalizeError({ status: 503, message: 'down' })
|
|
32
|
+
expect(err.code).toBe('http')
|
|
33
|
+
expect(err.retryable).toBe(true)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
test('http 429 is retryable', () => {
|
|
37
|
+
const err = normalizeError({ status: 429, message: 'slow down' })
|
|
38
|
+
expect(err.code).toBe('http')
|
|
39
|
+
expect(err.retryable).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
test('http 400 is not retryable', () => {
|
|
43
|
+
const err = normalizeError({ status: 400, message: 'bad' })
|
|
44
|
+
expect(err.code).toBe('http')
|
|
45
|
+
expect(err.retryable).toBe(false)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('falls back to unknown non-retryable', () => {
|
|
49
|
+
const err = normalizeError({})
|
|
50
|
+
expect(err.code).toBe('unknown')
|
|
51
|
+
expect(err.retryable).toBe(false)
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
describe('retryDecision', () => {
|
|
56
|
+
const config = resolveUploadConfig({ maxAttempts: 3 })
|
|
57
|
+
|
|
58
|
+
test('auth errors never retry', () => {
|
|
59
|
+
expect(
|
|
60
|
+
retryDecision(config, { phase: 'intent', attempt: 1, error: { code: 'auth', message: '' } as never }),
|
|
61
|
+
).toEqual({
|
|
62
|
+
retryable: false,
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('validation_failed never retries', () => {
|
|
67
|
+
expect(
|
|
68
|
+
retryDecision(config, {
|
|
69
|
+
phase: 'intent',
|
|
70
|
+
attempt: 1,
|
|
71
|
+
error: { code: 'validation_failed', message: '', reason: { code: 'empty_file' } } as never,
|
|
72
|
+
}),
|
|
73
|
+
).toEqual({ retryable: false })
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('aborted never retries', () => {
|
|
77
|
+
expect(
|
|
78
|
+
retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'aborted', message: '' } as never }),
|
|
79
|
+
).toEqual({ retryable: false })
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('stops retrying once attempt >= maxAttempts', () => {
|
|
83
|
+
expect(
|
|
84
|
+
retryDecision(config, { phase: 'upload', attempt: 3, error: { code: 'network', message: '' } as never }),
|
|
85
|
+
).toEqual({ retryable: false })
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('exponential backoff escalates by attempt', () => {
|
|
89
|
+
const a1 = retryDecision(config, { phase: 'upload', attempt: 1, error: { code: 'network', message: '' } as never })
|
|
90
|
+
const a2 = retryDecision(config, { phase: 'upload', attempt: 2, error: { code: 'network', message: '' } as never })
|
|
91
|
+
expect(a1.retryable).toBe(true)
|
|
92
|
+
expect(a2.retryable).toBe(true)
|
|
93
|
+
if (a1.retryable && a2.retryable) {
|
|
94
|
+
expect(a2.delayMs).toBeGreaterThan(a1.delayMs ?? 0)
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('isAbortError', () => {
|
|
100
|
+
test('matches the aborted shape', () => {
|
|
101
|
+
expect(isAbortError({ code: 'aborted' })).toBe(true)
|
|
102
|
+
expect(isAbortError({ code: 'aborted', reason: 'pause' })).toBe(true)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('rejects other shapes', () => {
|
|
106
|
+
expect(isAbortError(null)).toBe(false)
|
|
107
|
+
expect(isAbortError({})).toBe(false)
|
|
108
|
+
expect(isAbortError({ code: 'http' })).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('isMultipartIntent', () => {
|
|
113
|
+
test('accepts a complete multipart intent', () => {
|
|
114
|
+
expect(isMultipartIntent({ strategy: 'multipart', fileId: 'f', uploadId: 'u', partSize: 1 })).toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('rejects when fields are missing', () => {
|
|
118
|
+
expect(isMultipartIntent({ strategy: 'multipart' })).toBe(false)
|
|
119
|
+
expect(isMultipartIntent({ strategy: 'post', fileId: 'f', uploadId: 'u' })).toBe(false)
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
describe('calculateFileChecksum', () => {
|
|
124
|
+
test('produces a hex digest', async () => {
|
|
125
|
+
const file = new File([new Uint8Array([0, 1, 2, 3])], 'a.bin')
|
|
126
|
+
const h = await calculateFileChecksum(file)
|
|
127
|
+
expect(h).toMatch(/^[0-9a-f]+$/)
|
|
128
|
+
expect(h.length).toBeGreaterThan(0)
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('different files produce different hashes', async () => {
|
|
132
|
+
const a = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 9])], 'a.bin')
|
|
133
|
+
const b = new File([new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 1])], 'b.bin')
|
|
134
|
+
const ha = await calculateFileChecksum(a)
|
|
135
|
+
const hb = await calculateFileChecksum(b)
|
|
136
|
+
expect(ha).not.toBe(hb)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import { makeAbortError, normalizeAbortReason, UploadAbortError } from '../core/contracts/transport/transport.libs'
|
|
3
|
+
|
|
4
|
+
describe('normalizeAbortReason', () => {
|
|
5
|
+
test('passes bare strings through when valid', () => {
|
|
6
|
+
expect(normalizeAbortReason('pause')).toBe('pause')
|
|
7
|
+
expect(normalizeAbortReason('cancel')).toBe('cancel')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('unknown bare strings degrade to "unknown"', () => {
|
|
11
|
+
expect(normalizeAbortReason('shutdown')).toBe('unknown')
|
|
12
|
+
expect(normalizeAbortReason(undefined)).toBe('unknown')
|
|
13
|
+
expect(normalizeAbortReason(null)).toBe('unknown')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('extracts .reason and .kind from object payloads', () => {
|
|
17
|
+
expect(normalizeAbortReason({ reason: 'pause' })).toBe('pause')
|
|
18
|
+
expect(normalizeAbortReason({ kind: 'cancel' })).toBe('cancel')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('arrays do not crash and degrade to unknown', () => {
|
|
22
|
+
expect(normalizeAbortReason(['pause'])).toBe('unknown')
|
|
23
|
+
})
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
describe('UploadAbortError / makeAbortError', () => {
|
|
27
|
+
test('exposes a stable code', () => {
|
|
28
|
+
const e = makeAbortError('pause')
|
|
29
|
+
expect(e).toBeInstanceOf(UploadAbortError)
|
|
30
|
+
expect(e.code).toBe('aborted')
|
|
31
|
+
expect(e.reason).toBe('pause')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('defaults unknown reasons', () => {
|
|
35
|
+
expect(makeAbortError(undefined).reason).toBe('unknown')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('structured payloads with a known reason still normalize to pause/cancel', () => {
|
|
39
|
+
expect(new UploadAbortError({ reason: 'cancel', extra: 'meta' }).reason).toBe('cancel')
|
|
40
|
+
expect(new UploadAbortError({ kind: 'pause' }).reason).toBe('pause')
|
|
41
|
+
})
|
|
42
|
+
})
|